Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bcbcb41750 | |||
| 4c03e05e6c | |||
| 6cdb70bb61 | |||
| ed09794f4d | |||
| 52664f7168 | |||
| 0469272bd6 | |||
| 6638f8b29e | |||
| 794793f992 | |||
| 3fba310b9e | |||
| 0fa5b31f4f | |||
| 68d2319234 | |||
| e148f995a8 | |||
| 873008dde8 | |||
| dddc1cd081 | |||
| 2ce3a5efd3 | |||
| 54916aff86 | |||
| 9a289e1e1f | |||
| 9bca7e53e3 | |||
| 70dde8cbfb | |||
| 528f56254d | |||
| ff60fdf956 | |||
| 06b0bacce1 | |||
| a83ed58864 | |||
| cdcb032e45 | |||
| c8b1c4b8ff |
@@ -0,0 +1,26 @@
|
||||
"""add profile_completed to users
|
||||
|
||||
Issue #1718:微信新用户首次登录需设置昵称(PATCH /auth/me)。
|
||||
- users.profile_completed:资料是否已完善;存量行默认 True(不触发引导),
|
||||
微信新建用户在应用层置 False。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "068_user_profile_completed"
|
||||
down_revision = "067_celery_task_id"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("profile_completed", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "profile_completed")
|
||||
@@ -1,4 +1,5 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
Canonical authentication API routes.
|
||||
|
||||
The route layer is intentionally thin: repository construction lives in
|
||||
@@ -15,7 +16,7 @@ from app.config import settings
|
||||
from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
|
||||
from packages.adapters.redis import NoopSessionStore
|
||||
from packages.adapters.smtp import NoopEmailService
|
||||
@@ -85,6 +86,22 @@ class CurrentUserResponse(BaseModel):
|
||||
phone_verified: bool = False
|
||||
binding_complete: bool = False
|
||||
wechat_bound: bool = False
|
||||
profile_completed: bool = True
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
"""用户资料负载(PATCH /me、绑定/解绑接口复用;字段与 GET /auth/me 一致,前端 normalizeUser 直接消费)"""
|
||||
|
||||
user_id: str
|
||||
email: str
|
||||
username: str
|
||||
display_name: str
|
||||
email_verified: bool
|
||||
phone: str = ""
|
||||
phone_verified: bool = False
|
||||
binding_complete: bool = False
|
||||
wechat_bound: bool = False
|
||||
profile_completed: bool = True
|
||||
|
||||
|
||||
class PasswordResetRequestModel(BaseModel):
|
||||
@@ -274,9 +291,51 @@ async def get_current_user_info(
|
||||
phone_verified=user.phone_verified,
|
||||
binding_complete=binding_complete,
|
||||
wechat_bound=bool(user.wechat_openid),
|
||||
profile_completed=user.profile_completed,
|
||||
)
|
||||
|
||||
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
"""更新个人资料请求(当前仅支持昵称)"""
|
||||
|
||||
display_name: str
|
||||
|
||||
@field_validator("display_name")
|
||||
@classmethod
|
||||
def _validate_display_name(cls, v: str) -> str:
|
||||
name = (v or "").strip()
|
||||
if not name:
|
||||
raise ValueError("昵称不能为空白")
|
||||
if len(name) > 20:
|
||||
raise ValueError("昵称长度需在 1-20 个字符之间")
|
||||
return name
|
||||
|
||||
|
||||
class UpdateProfileResponse(BaseModel):
|
||||
"""更新资料响应:前端 normalizeUser(response.user) 直接消费"""
|
||||
|
||||
user: UserProfileResponse
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UpdateProfileResponse)
|
||||
async def update_current_user_profile(
|
||||
request: UpdateProfileRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> UpdateProfileResponse:
|
||||
"""更新当前登录用户昵称(微信新用户首次设置昵称后置 profile_completed=True)。"""
|
||||
user = current_user.user
|
||||
user.display_name = request.display_name # 已 strip(validator)
|
||||
if not user.profile_completed:
|
||||
user.profile_completed = True
|
||||
user_repository.save(user)
|
||||
|
||||
logger.info("[资料更新] 用户 %s 更新昵称,profile_completed=%s", user.id, user.profile_completed)
|
||||
# 重新读取,确保返回的是持久化后的最新状态
|
||||
fresh = user_repository.find_by_id(user.id) or user
|
||||
return UpdateProfileResponse(user=_user_profile(fresh))
|
||||
|
||||
|
||||
class _NoopSessionStore(NoopSessionStore):
|
||||
pass
|
||||
|
||||
@@ -507,34 +566,20 @@ class WechatBindCompleteRequest(BaseModel):
|
||||
state: str = ""
|
||||
|
||||
|
||||
class WechatBindUserProfile(BaseModel):
|
||||
"""绑定/解绑后返回的用户信息(字段对齐 /auth/me,前端 normalizeUser 直接消费)"""
|
||||
|
||||
user_id: str
|
||||
email: str
|
||||
username: str
|
||||
display_name: str
|
||||
email_verified: bool
|
||||
phone: str = ""
|
||||
phone_verified: bool = False
|
||||
binding_complete: bool = False
|
||||
wechat_bound: bool = False
|
||||
|
||||
|
||||
class WechatBindCompleteResponse(BaseModel):
|
||||
success: bool
|
||||
user: WechatBindUserProfile
|
||||
user: UserProfileResponse
|
||||
|
||||
|
||||
class WechatUnbindResponse(BaseModel):
|
||||
success: bool
|
||||
|
||||
|
||||
def _wechat_user_profile(user) -> WechatBindUserProfile:
|
||||
def _user_profile(user) -> UserProfileResponse:
|
||||
binding_complete = bool(
|
||||
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
|
||||
)
|
||||
return WechatBindUserProfile(
|
||||
return UserProfileResponse(
|
||||
user_id=user.id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
@@ -544,6 +589,7 @@ def _wechat_user_profile(user) -> WechatBindUserProfile:
|
||||
phone_verified=user.phone_verified,
|
||||
binding_complete=binding_complete,
|
||||
wechat_bound=bool(user.wechat_openid),
|
||||
profile_completed=user.profile_completed,
|
||||
)
|
||||
|
||||
|
||||
@@ -589,7 +635,7 @@ async def wechat_bind(
|
||||
raise HTTPException(status_code=http_status, detail=error)
|
||||
|
||||
logger.info("[微信绑定] 用户 %s 绑定成功 openid=%s", current_user.user.id, wechat_user.openid[:8])
|
||||
return WechatBindCompleteResponse(success=True, user=_wechat_user_profile(result.user))
|
||||
return WechatBindCompleteResponse(success=True, user=_user_profile(result.user))
|
||||
|
||||
|
||||
@router.delete("/wechat/bind", response_model=WechatUnbindResponse)
|
||||
|
||||
@@ -420,42 +420,77 @@ def create_preview_generation_task(
|
||||
logger.error("[预览生成] 创建失败: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="创建预览生成任务失败,请稍后再试") from e
|
||||
|
||||
# ── 克隆独立变体 plan:N 个预览全部克隆(预览不污染源 plan)──
|
||||
# 源 plan 不存在(无编辑历史)时各任务走自身随机选片流程,不克隆。
|
||||
# ── 独立变体 plan(#1743)──
|
||||
# count=1:克隆源 plan(预览不污染源 plan,仅起点重算),行为与旧版一致;
|
||||
# count>1:变体 0 保留源 plan,变体 1..N-1 用 reselect_plan_for_variant 完整
|
||||
# 重跑单视频选片(素材洗牌+镜头洗牌+起点随机+跨变体避让+批次 20% 重叠重选),
|
||||
# 所见即所得——预览变体差异即正式成片差异。
|
||||
source_plan_id = created_tasks[0].source_edit_plan_id if created_tasks else ""
|
||||
if source_plan_id:
|
||||
if source_plan_id and count == 1:
|
||||
# 单预览:克隆一份(原逻辑)
|
||||
try:
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
_plan_svc = EditPlanService(db)
|
||||
for variant_index in range(count):
|
||||
variant_plan = _plan_svc.clone_plan_for_variant(
|
||||
source_plan_id,
|
||||
created_by_user_id=user_id,
|
||||
name_suffix="预览变体",
|
||||
)
|
||||
variant_plan_ids.append(variant_plan.id)
|
||||
except Exception as e:
|
||||
logger.error("[预览生成] 克隆预览 plan 异常: %s", e, exc_info=True)
|
||||
for t in created_tasks:
|
||||
_mark_task_failed(generation_task_repository, t, "预览计划创建失败")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="创建预览任务失败:无法生成独立剪辑计划,请重试",
|
||||
) from e
|
||||
elif source_plan_id and count > 1:
|
||||
try:
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
_plan_svc = EditPlanService(db)
|
||||
# 变体 0 直接用源 plan;变体 1..N-1 独立选片
|
||||
variant_plan_ids.append(source_plan_id)
|
||||
batch_asset_pool = list(dict.fromkeys(request.asset_ids or []))
|
||||
for variant_index in range(1, count):
|
||||
last_err: Exception | None = None
|
||||
variant_plan = None
|
||||
for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动
|
||||
try:
|
||||
variant_plan = _plan_svc.clone_plan_for_variant(
|
||||
variant_plan = _plan_svc.reselect_plan_for_variant(
|
||||
source_plan_id,
|
||||
batch_asset_pool,
|
||||
created_by_user_id=user_id,
|
||||
name_suffix=f"预览变体{variant_index + 1}" if count > 1 else "预览变体",
|
||||
name_suffix=f"预览变体{variant_index + 1}",
|
||||
)
|
||||
break
|
||||
except Exception as clone_err: # noqa: PERF203
|
||||
last_err = clone_err
|
||||
except ValueError as ve:
|
||||
logger.warning("[预览生成] 变体独立选片失败(素材不足): %s", ve)
|
||||
for t in created_tasks:
|
||||
_mark_task_failed(generation_task_repository, t, "预览变体选片失败")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"批量预览第 {variant_index + 1} 个视频无法独立选片:{ve}。"
|
||||
"请增加素材库中的视频素材后重试。",
|
||||
) from ve
|
||||
except Exception as reselection_err: # noqa: PERF203
|
||||
last_err = reselection_err
|
||||
logger.warning(
|
||||
"[预览生成] 克隆变体 plan 失败(尝试%d/2): variant=%d error=%s",
|
||||
"[预览生成] 变体独立选片失败(尝试%d/2): variant=%d error=%s",
|
||||
_attempt + 1,
|
||||
variant_index,
|
||||
clone_err,
|
||||
reselection_err,
|
||||
exc_info=True,
|
||||
)
|
||||
if variant_plan is None:
|
||||
logger.error(
|
||||
"[预览生成] 克隆预览变体 plan 重试仍失败: variant=%d source=%s",
|
||||
"[预览生成] 变体独立选片重试仍失败: variant=%d source=%s",
|
||||
variant_index,
|
||||
source_plan_id,
|
||||
exc_info=last_err,
|
||||
)
|
||||
# 标记已创建任务失败
|
||||
for t in created_tasks:
|
||||
_mark_task_failed(generation_task_repository, t, "预览变体计划创建失败")
|
||||
raise HTTPException(
|
||||
@@ -466,7 +501,7 @@ def create_preview_generation_task(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("[预览生成] 克隆变体 plan 异常: %s", e, exc_info=True)
|
||||
logger.error("[预览生成] 变体 plan 生成异常: %s", e, exc_info=True)
|
||||
for t in created_tasks:
|
||||
_mark_task_failed(generation_task_repository, t, "预览变体计划创建失败")
|
||||
raise HTTPException(
|
||||
|
||||
@@ -123,6 +123,7 @@ def _select_assets_from_library(
|
||||
assets: list,
|
||||
mode: str,
|
||||
count: int,
|
||||
rng=None,
|
||||
) -> list[str]:
|
||||
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
|
||||
|
||||
@@ -130,6 +131,8 @@ def _select_assets_from_library(
|
||||
assets: 素材库中所有素材(Asset 实体列表)
|
||||
mode: 选取模式 — all=全部, smart=智能匹配(多维度评分+多样性)
|
||||
count: 选取数量,0 表示全部(仅 smart 模式有效)
|
||||
rng: 可选随机源(smart 模式排序噪声用),生产环境不传则内部随机;
|
||||
测试可注入固定种子或零噪声随机源获得确定性结果。
|
||||
|
||||
Returns:
|
||||
选中的素材 ID 列表
|
||||
@@ -142,8 +145,9 @@ def _select_assets_from_library(
|
||||
if mode == "smart":
|
||||
# 智能匹配:统一使用 packages/domain/smart_match.py 的多维评分+多样性选取
|
||||
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
|
||||
# 排序注入随机噪声(#1743):同分素材每次选出不同组合,从素材组合层面降重
|
||||
limit = count if count > 0 else None
|
||||
results = smart_select_assets(ready_video_assets, limit=limit, kind="video")
|
||||
results = smart_select_assets(ready_video_assets, limit=limit, kind="video", rng=rng)
|
||||
return [r.asset.id for r in results]
|
||||
|
||||
# 默认 all 模式:返回全部 ready 视频素材
|
||||
@@ -432,39 +436,83 @@ def create_generation_task(
|
||||
logger.info("画中画已下线,strategy_id %s → one_take", effective_strategy_id)
|
||||
effective_strategy_id = "one_take"
|
||||
|
||||
# 批量生成时每个任务关联独立克隆 plan(片段起点重算),
|
||||
# 禁止 N 条任务共用同一 source_edit_plan_id 导致片段一模一样。
|
||||
# 在创建任何任务【之前】预克隆全部变体:克隆失败直接中断(此时无脏数据),
|
||||
# 绝不静默退回共用源 plan(否则批量视频内容重复,违反去重诉求)。
|
||||
# 批量生成(count>1):每个变体必须走与单视频完全相同的独立选片流程(#1743)。
|
||||
# - 变体 0 保留源 plan(保留用户编辑结果);
|
||||
# - 变体 1..N-1 用 reselect_plan_for_variant 完整重跑选片(素材洗牌 + 镜头洗牌
|
||||
# + 起点随机 + 跨变体区间避让 + 批次 20% 重叠重选),而非"克隆只改起点";
|
||||
# - count>1 但没有源 plan(前端未传 source_edit_plan_id 且无模板 plan)时,
|
||||
# 不允许 N 个任务兜底共用同一 plan,直接 4xx 中断(宁可不生成,也不出同源成片)。
|
||||
# 在创建任何任务【之前】预生成全部变体 plan:失败直接中断(此时无脏数据)。
|
||||
variant_plan_ids: list[str] = []
|
||||
if count > 1 and request.source_edit_plan_id:
|
||||
if count > 1:
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
_plan_svc = EditPlanService(db)
|
||||
|
||||
# 解析批量源 plan:优先前端传入;否则按 template_id + user 查最新(与单任务兜底同源)
|
||||
batch_source_plan_id = request.source_edit_plan_id
|
||||
if not batch_source_plan_id and request.template_id:
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
|
||||
_latest = (
|
||||
db.query(EditPlanModel)
|
||||
.filter(
|
||||
EditPlanModel.template_id == request.template_id,
|
||||
EditPlanModel.created_by_user_id == user_id,
|
||||
)
|
||||
.order_by(EditPlanModel.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if _latest:
|
||||
batch_source_plan_id = _latest.id
|
||||
except Exception:
|
||||
logger.warning("[生成任务] 批量源 plan 解析失败", exc_info=True)
|
||||
|
||||
if not batch_source_plan_id:
|
||||
# 无任何可用源 plan:批量变体无从选片,明确报错,严禁静默共用/同源
|
||||
logger.error("[生成任务] 批量 count=%d 但无可编辑计划(无 source_edit_plan_id/template plan)", count)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="批量生成需要先完成预览生成(缺少剪辑计划)。请先生成预览后再批量创建。",
|
||||
)
|
||||
|
||||
# 批次素材池:请求显式素材 + 库自动匹配素材(resolved_asset_ids)
|
||||
batch_asset_pool = list(dict.fromkeys(resolved_asset_ids or []))
|
||||
|
||||
for task_index in range(1, count):
|
||||
variant = None
|
||||
last_err: Exception | None = None
|
||||
for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动
|
||||
try:
|
||||
variant = _plan_svc.clone_plan_for_variant(
|
||||
request.source_edit_plan_id,
|
||||
variant = _plan_svc.reselect_plan_for_variant(
|
||||
batch_source_plan_id,
|
||||
batch_asset_pool,
|
||||
created_by_user_id=user_id,
|
||||
name_suffix=f"批量{task_index + 1}",
|
||||
)
|
||||
break
|
||||
except Exception as clone_err: # noqa: PERF203
|
||||
last_err = clone_err
|
||||
except ValueError as ve:
|
||||
# 素材不足等可预期错误:不重试,直接中断并给出明确提示
|
||||
logger.warning("[生成任务] 变体独立选片失败(素材不足): %s", ve)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"批量生成第 {task_index + 1} 个视频无法独立选片:{ve}。"
|
||||
"请增加素材库中的视频素材后重试。",
|
||||
) from ve
|
||||
except Exception as reselection_err: # noqa: PERF203
|
||||
last_err = reselection_err
|
||||
logger.warning(
|
||||
"[生成任务] 克隆变体 plan 失败(尝试%d/2): source=%s error=%s",
|
||||
"[生成任务] 变体独立选片失败(尝试%d/2): source=%s error=%s",
|
||||
_attempt + 1,
|
||||
request.source_edit_plan_id,
|
||||
clone_err,
|
||||
batch_source_plan_id,
|
||||
reselection_err,
|
||||
exc_info=True,
|
||||
)
|
||||
if variant is None:
|
||||
logger.error(
|
||||
"[生成任务] 克隆变体 plan 重试仍失败,中断批量创建: source=%s",
|
||||
request.source_edit_plan_id,
|
||||
"[生成任务] 变体独立选片重试仍失败,中断批量创建: source=%s",
|
||||
batch_source_plan_id,
|
||||
exc_info=last_err,
|
||||
)
|
||||
raise HTTPException(
|
||||
@@ -475,10 +523,11 @@ def create_generation_task(
|
||||
|
||||
try:
|
||||
for task_index in range(count):
|
||||
# 第 1 条复用源 plan(保留用户编辑结果);其余使用预克隆的独立变体 plan。
|
||||
# 无源 plan(source_edit_plan_id 为空)时无可克隆对象,variant_plan_ids
|
||||
# 为空列表:各任务走自身随机选片流程,不做索引访问(防 IndexError)
|
||||
effective_plan_id = request.source_edit_plan_id
|
||||
# 变体 0 复用源 plan(保留用户编辑结果);变体 1..N-1 用预生成的独立选片 plan。
|
||||
# count>1 时上方已保证存在源 plan 且变体 plan 数量 == count-1。
|
||||
effective_plan_id = (
|
||||
request.source_edit_plan_id or batch_source_plan_id if count > 1 else request.source_edit_plan_id
|
||||
)
|
||||
if task_index > 0 and variant_plan_ids:
|
||||
effective_plan_id = variant_plan_ids[task_index - 1]
|
||||
|
||||
@@ -523,7 +572,19 @@ def create_generation_task(
|
||||
try:
|
||||
# 兜底关联编辑计划:前端未传 source_edit_plan_id 时,
|
||||
# 通过 template_id + user_id 在 DB 层直接查找最新的 plan。
|
||||
# 必须在 enqueue 之前执行,避免 worker 读取时 source_edit_plan_id 为空(竞态条件)
|
||||
# 必须在 enqueue 之前执行,避免 worker 读取时 source_edit_plan_id 为空(竞态条件)。
|
||||
# #1743:批量(count>1)场景严禁兜底共用——变体 plan 已在上方预生成,
|
||||
# 走到这里还缺 plan 说明预生成漏配,直接报错中断,不允许 N 任务关联同一 plan。
|
||||
if not task.source_edit_plan_id and count > 1:
|
||||
logger.error(
|
||||
"[生成任务] 批量任务缺少独立 plan(禁止共用兜底): task_index=%d task_id=%s",
|
||||
task_index,
|
||||
task.id,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="创建批量任务失败:变体剪辑计划缺失,请重新预览后再批量生成。",
|
||||
)
|
||||
if not task.source_edit_plan_id and request.template_id:
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
|
||||
@@ -108,9 +108,8 @@ def _infer_mime_type_from_storage_key(storage_key: str) -> str:
|
||||
return "video/mp4" # default
|
||||
|
||||
|
||||
# 兜底去重:无 file_hash / client_upload_id 时,同库同名近期活动记录视为重复
|
||||
# 兜底去重:无 file_hash / client_upload_id 且大小已知时,同库同名同大小近期活动记录视为重复
|
||||
FALLBACK_DEDUP_WINDOW_MINUTES = 30
|
||||
ACTIVE_ASSET_STATUSES = (AssetStatus.UPLOADING, AssetStatus.PROCESSING)
|
||||
|
||||
|
||||
def _find_duplicate_asset(
|
||||
@@ -126,8 +125,12 @@ def _find_duplicate_asset(
|
||||
|
||||
1. client_upload_id(客户端幂等 token,同一次上传的重试保持一致)
|
||||
2. file_hash(内容哈希,不同上传只要内容相同即去重)
|
||||
3. 兜底:同库 + 同文件名(+同大小)且 30 分钟内仍处 uploading/processing
|
||||
的记录——旧客户端不传 hash/token 时,防止 complete 超时重试反复建占位。
|
||||
3. 兜底(严格模式,宁可漏判不可误杀):file_hash 与 client_upload_id
|
||||
均缺失、且 file_size > 0 时,同库 + 同文件名 + **同大小** 且 30 分钟内
|
||||
仍处 uploading/processing 的记录才判重。
|
||||
- file_hash 非空时跳过兜底(hash 已代表内容;同名但内容全新的视频
|
||||
如 iPhone 的 IMG_xxxx.MOV 绝不能被同名占位误杀)
|
||||
- file_size=0(未知)时不允许仅凭同名 + processing 判重,直接放行
|
||||
|
||||
全部为鸭子类型调用:旧仓储无对应方法时静默跳过,不破坏既有实现。
|
||||
"""
|
||||
@@ -156,24 +159,35 @@ def _find_duplicate_asset(
|
||||
existing.id,
|
||||
)
|
||||
return existing
|
||||
if filename:
|
||||
# 同名兜底去重(最后防线,严格模式):
|
||||
# - 仅当 file_hash / client_upload_id 均缺失时启用(hash 能代表内容时不靠同名猜)
|
||||
# - file_size 必须 > 0 且与记录大小严格一致;大小未知(0)直接放行
|
||||
# - 只命中近期 UPLOADING/PROCESSING 活动记录(READY 历史素材不拦)
|
||||
if filename and not file_hash and not client_upload_id and file_size and file_size > 0:
|
||||
find_recent = getattr(asset_repository, "find_recent_active_by_library_and_name", None)
|
||||
if callable(find_recent):
|
||||
existing = find_recent(
|
||||
library_id=library_id,
|
||||
name=filename,
|
||||
within_minutes=FALLBACK_DEDUP_WINDOW_MINUTES,
|
||||
file_size=file_size or 0,
|
||||
file_size=file_size,
|
||||
)
|
||||
if existing is not None and getattr(existing, "status", None) in ACTIVE_ASSET_STATUSES:
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材幂等兜底命中(近期活动同名记录): library=%s name=%s asset=%s status=%s",
|
||||
"素材幂等兜底命中(近期同名同大小活动记录): library=%s name=%s asset=%s status=%s size=%s",
|
||||
library_id,
|
||||
filename,
|
||||
getattr(existing, "id", "?"),
|
||||
getattr(existing, "status", "?"),
|
||||
getattr(existing, "status", None),
|
||||
file_size,
|
||||
)
|
||||
return existing
|
||||
elif filename and not file_hash and not client_upload_id and not file_size:
|
||||
logger.debug(
|
||||
"同名兜底去重跳过(file_size 未知,宁可放行不可误杀): library=%s name=%s",
|
||||
library_id,
|
||||
filename,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -187,8 +201,44 @@ def _create_pending_asset(
|
||||
user_id,
|
||||
file_hash="",
|
||||
client_upload_id="",
|
||||
file_size: int = 0,
|
||||
):
|
||||
"""立即创建一条 PROCESSING 状态的 Asset 记录,使前端能马上看到新素材。"""
|
||||
"""立即创建或复用一条 PROCESSING 状态的 Asset 记录。
|
||||
|
||||
find-or-create:prepare 阶段已按 file_hash/client_upload_id 预建的占位记录
|
||||
会被 find_by_library_and_file_hash/find_by_library_and_client_upload_id 命中,
|
||||
直接复用并补齐字段(避免 pre-create + complete 重复建两条)。
|
||||
"""
|
||||
# 1. 按 client_upload_id / file_hash 查找现有记录
|
||||
existing = None
|
||||
if client_upload_id:
|
||||
find_by_cuid = getattr(asset_repository, "find_by_library_and_client_upload_id", None)
|
||||
if callable(find_by_cuid):
|
||||
existing = find_by_cuid(library_id=library_id, client_upload_id=client_upload_id)
|
||||
if existing is None and file_hash:
|
||||
existing = asset_repository.find_by_library_and_file_hash(library_id=library_id, file_hash=file_hash)
|
||||
if existing is not None:
|
||||
# 补齐字段(幂等:避免重复建记录,前端已拿到 asset_id)
|
||||
changed = False
|
||||
if file_hash and not existing.file_hash:
|
||||
existing.file_hash = file_hash
|
||||
changed = True
|
||||
if client_upload_id and not existing.client_upload_id:
|
||||
existing.client_upload_id = client_upload_id
|
||||
changed = True
|
||||
if file_size and not existing.file_size:
|
||||
existing.file_size = file_size
|
||||
changed = True
|
||||
if existing.status not in (AssetStatus.PROCESSING, AssetStatus.UPLOADING):
|
||||
existing.status = AssetStatus.PROCESSING
|
||||
changed = True
|
||||
if changed:
|
||||
try:
|
||||
asset_repository.update(existing)
|
||||
except Exception: # noqa: BLE001 — 字段补齐失败不阻塞主流程
|
||||
pass
|
||||
return existing
|
||||
|
||||
asset = Asset.create(
|
||||
project_id=project_id,
|
||||
library_id=library_id,
|
||||
@@ -199,6 +249,7 @@ def _create_pending_asset(
|
||||
uploaded_by_user_id=user_id,
|
||||
file_hash=file_hash,
|
||||
client_upload_id=client_upload_id,
|
||||
file_size=file_size,
|
||||
)
|
||||
return asset_repository.create(asset)
|
||||
|
||||
@@ -243,9 +294,15 @@ async def prepare_direct_upload(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> DirectUploadPrepareResponse:
|
||||
"""创建浏览器直传 OSS 的短期表单签名。"""
|
||||
"""创建浏览器直传 OSS 的短期表单签名,并在签名前按 file_hash/client_upload_id 去重。
|
||||
|
||||
命中去重:直接返回 duplicated=True + skip_transfer=True(前端跳过 OSS 直传),
|
||||
未命中:正常签名 OSS 并立即预建一条 PROCESSING 状态的 asset 记录占住
|
||||
file_hash 闸门,响应带 asset_id 供前端/后续 complete 关联。
|
||||
"""
|
||||
settings = get_settings()
|
||||
max_size_bytes = settings.OSS_DIRECT_UPLOAD_MAX_MB * 1024 * 1024
|
||||
if request.file_size > max_size_bytes:
|
||||
@@ -264,8 +321,39 @@ async def prepare_direct_upload(
|
||||
asset_library_repository,
|
||||
)
|
||||
|
||||
file_id = uuid4().hex[:8]
|
||||
safe_filename = request.filename.replace("/", "_").replace("\\", "_")
|
||||
|
||||
# ── prepare 阶段去重:OSS 签名之前先查已存在素材 ──
|
||||
if request.file_hash or request.client_upload_id:
|
||||
existing = _find_duplicate_asset(
|
||||
asset_repository,
|
||||
library_id=request.library_id,
|
||||
file_hash=request.file_hash,
|
||||
client_upload_id=request.client_upload_id,
|
||||
filename=request.filename,
|
||||
file_size=request.file_size,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"prepare 命中去重: library=%s hash=%s cuid=%s existing_asset=%s",
|
||||
request.library_id,
|
||||
request.file_hash,
|
||||
request.client_upload_id,
|
||||
existing.id,
|
||||
)
|
||||
return DirectUploadPrepareResponse(
|
||||
upload_url="",
|
||||
method="",
|
||||
storage_key=existing.storage_key,
|
||||
expires_at="",
|
||||
fields={},
|
||||
max_size_bytes=0,
|
||||
duplicated=True,
|
||||
skip_transfer=True,
|
||||
asset_id=existing.id,
|
||||
)
|
||||
|
||||
file_id = uuid4().hex[:8]
|
||||
storage_key = f"uploads/{file_id}/{safe_filename}"
|
||||
try:
|
||||
payload = storage_service.create_direct_upload_post(
|
||||
@@ -284,6 +372,27 @@ async def prepare_direct_upload(
|
||||
detail=f"Failed to prepare upload: {type(error).__name__}",
|
||||
) from error
|
||||
|
||||
# ── 预建 asset 占位:占住 file_hash/client_upload_id 闸门,避免并发重复上传 ──
|
||||
pending_asset_id = ""
|
||||
if request.file_hash or request.client_upload_id:
|
||||
try:
|
||||
pending = _create_pending_asset(
|
||||
asset_repository=asset_repository,
|
||||
project_id=request.project_id,
|
||||
library_id=request.library_id,
|
||||
storage_key=storage_key,
|
||||
filename=safe_filename,
|
||||
mime_type=validated_content_type,
|
||||
user_id=authenticated_user.user.id,
|
||||
file_hash=request.file_hash,
|
||||
client_upload_id=request.client_upload_id,
|
||||
file_size=request.file_size,
|
||||
)
|
||||
pending_asset_id = pending.id
|
||||
except Exception as error:
|
||||
# 预建失败不阻塞签名:complete 仍可按 OSS 文件 + hash 兜底去重
|
||||
logger.warning("预建 asset 占位失败,降级走 old flow: %s", error)
|
||||
|
||||
return DirectUploadPrepareResponse(
|
||||
upload_url=str(payload["url"]),
|
||||
method=str(payload["method"]),
|
||||
@@ -291,6 +400,9 @@ async def prepare_direct_upload(
|
||||
expires_at=str(payload["expires_at"]),
|
||||
fields={str(key): str(value) for key, value in dict(payload["fields"]).items()},
|
||||
max_size_bytes=max_size_bytes,
|
||||
duplicated=False,
|
||||
skip_transfer=False,
|
||||
asset_id=pending_asset_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -360,6 +472,7 @@ async def complete_direct_upload(
|
||||
user_id=authenticated_user.user.id,
|
||||
file_hash=request.file_hash,
|
||||
client_upload_id=request.client_upload_id,
|
||||
file_size=request.file_size,
|
||||
)
|
||||
|
||||
job = _submit_ingest_job(
|
||||
|
||||
@@ -16,6 +16,7 @@ class DirectUploadPrepareRequest(BaseModel):
|
||||
content_type: str = Field(default="application/octet-stream", min_length=1, max_length=100)
|
||||
file_size: int = Field(..., gt=0)
|
||||
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
|
||||
client_upload_id: str = Field(default="", max_length=64, description="客户端幂等 token(同一次上传的重试保持一致)")
|
||||
|
||||
|
||||
class DirectUploadPrepareResponse(BaseModel):
|
||||
@@ -25,6 +26,9 @@ class DirectUploadPrepareResponse(BaseModel):
|
||||
expires_at: str
|
||||
fields: dict[str, str]
|
||||
max_size_bytes: int
|
||||
duplicated: bool = False
|
||||
skip_transfer: bool = False
|
||||
asset_id: str = ""
|
||||
|
||||
|
||||
class DirectUploadCompleteRequest(BaseModel):
|
||||
|
||||
@@ -409,8 +409,13 @@ class EditPlanService:
|
||||
clip_type=clip_item.get("clip_type", "main"),
|
||||
order=order,
|
||||
asset_id=clip_item.get("asset_id", ""),
|
||||
text_content=clip_item.get("text_content", ""),
|
||||
start_time=clip_item.get("start_time", 0.0),
|
||||
duration=clip_item.get("duration", 0.0),
|
||||
transition_effect=clip_item.get("transition_effect", "cut"),
|
||||
transition_duration=clip_item.get("transition_duration", 0.0),
|
||||
playback_speed=clip_item.get("playback_speed", 1.0),
|
||||
config=clip_item.get("config") or None,
|
||||
)
|
||||
model = EditPlanClipModel(
|
||||
id=clip.id,
|
||||
@@ -459,6 +464,136 @@ class EditPlanService:
|
||||
logger.exception("事务性替换片段失败: plan_id=%s", plan_id)
|
||||
raise
|
||||
|
||||
def reselect_plan_for_variant(
|
||||
self,
|
||||
source_plan_id: str,
|
||||
candidate_asset_ids: list[str],
|
||||
*,
|
||||
created_by_user_id: str = "",
|
||||
name_suffix: str = "变体",
|
||||
rng=None,
|
||||
) -> EditPlan:
|
||||
"""为批量变体生成独立 plan:完整重跑单视频选片流程(#1743)。
|
||||
|
||||
与 clone_plan_for_variant(只重算起点、素材/顺序不变)不同,本方法:
|
||||
- 源 plan 片段骨架(clip_type/order/duration/文案/转场)保留;
|
||||
- 素材池 shuffle 随机分配 + main 片段顺序洗牌;
|
||||
- 起点走场景镜头洗牌/随机起点/历史区间避让(与单视频同一入口);
|
||||
- 批次内同素材区间重叠 >20% 自动重选起点;
|
||||
- 新片段区间 record_used_segments 写回素材 metadata(跨变体/跨任务避让)。
|
||||
|
||||
Args:
|
||||
source_plan_id: 源 plan(任务 0 / 预览源)。
|
||||
candidate_asset_ids: 素材池(源 plan 素材 ∪ 批次素材)。
|
||||
created_by_user_id: 新 plan 归属用户。
|
||||
name_suffix: plan 名后缀。
|
||||
rng: 可选随机数(测试注入种子)。
|
||||
|
||||
Raises:
|
||||
ValueError: 源 plan 不存在/无片段、素材池为空或时长全未知。
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
from packages.domain.plan_generator_utils import extract_scene_points_from_metadata
|
||||
from packages.domain.variant_plan_selector import reselect_clips_for_variant
|
||||
|
||||
source = self.get_plan_or_raise(source_plan_id)
|
||||
|
||||
# 分页读取源 plan 全部片段
|
||||
clips: List[EditPlanClip] = []
|
||||
skip, page = 0, 500
|
||||
while True:
|
||||
batch = self._clip_repo.list_by_plan(source_plan_id, skip=skip, limit=page)
|
||||
if not batch:
|
||||
break
|
||||
clips.extend(batch)
|
||||
if len(batch) < page:
|
||||
break
|
||||
skip += page
|
||||
if not clips:
|
||||
raise ValueError(f"源 plan 无片段,无法生成变体: {source_plan_id}")
|
||||
|
||||
source_clips_data = [
|
||||
{
|
||||
"order": c.order if c.order is not None else i,
|
||||
"asset_id": c.asset_id,
|
||||
"start_time": float(c.start_time or 0.0),
|
||||
"duration": float(c.duration or 0.0),
|
||||
"clip_type": c.clip_type,
|
||||
"playback_speed": float(c.playback_speed or 1.0),
|
||||
"transition_effect": c.transition_effect,
|
||||
"transition_duration": float(c.transition_duration or 0.0),
|
||||
"text_content": c.text_content or "",
|
||||
"config": c.config or {},
|
||||
}
|
||||
for i, c in enumerate(clips)
|
||||
]
|
||||
|
||||
db = self._clip_repo.session
|
||||
|
||||
# 素材池 = 源 plan 素材 ∪ 调用方传入素材(去重保序)
|
||||
pool_ids: list[str] = []
|
||||
seen = set()
|
||||
for aid in [c.asset_id for c in clips if c.asset_id] + list(candidate_asset_ids or []):
|
||||
if aid and aid not in seen:
|
||||
seen.add(aid)
|
||||
pool_ids.append(aid)
|
||||
|
||||
# 时长 + 场景点
|
||||
durations: dict[str, float] = {}
|
||||
scene_points: dict[str, list[float]] = {}
|
||||
if pool_ids:
|
||||
for m in db.query(AssetModel).filter(AssetModel.id.in_(pool_ids)).all():
|
||||
durations[m.id] = float(getattr(m, "duration", 0.0) or 0.0)
|
||||
pts = extract_scene_points_from_metadata(getattr(m, "metadata", None))
|
||||
if pts:
|
||||
scene_points[m.id] = pts
|
||||
|
||||
historical = get_used_segments(db, pool_ids)
|
||||
|
||||
# 创建新 plan(复制模板归属与 config)
|
||||
new_plan = self.create_plan(
|
||||
template_id=source.template_id,
|
||||
name=f"{source.name or '剪辑计划'} · {name_suffix}",
|
||||
config=dict(source.config or {}),
|
||||
total_duration=source.total_duration,
|
||||
project_id=source.project_id or "",
|
||||
created_by_user_id=created_by_user_id or (source.created_by_user_id or ""),
|
||||
)
|
||||
|
||||
# 批次内区间:以源 plan(变体 0)片段为初始避让对象
|
||||
batch_segments: dict[str, list[tuple[float, float]]] = {}
|
||||
for c in clips:
|
||||
if c.asset_id and float(c.duration or 0) > 0:
|
||||
st = float(c.start_time or 0.0)
|
||||
batch_segments.setdefault(c.asset_id, []).append((st, st + float(c.duration)))
|
||||
|
||||
clips_data = reselect_clips_for_variant(
|
||||
source_clips_data,
|
||||
pool_ids,
|
||||
asset_durations=durations,
|
||||
asset_scene_points=scene_points,
|
||||
historical_used_segments=historical,
|
||||
batch_segments=batch_segments,
|
||||
rng=rng,
|
||||
)
|
||||
|
||||
# 片段区间写回素材 metadata(与落库同事务;replace_all_clips_transactional 内 commit)
|
||||
for item in clips_data:
|
||||
aid = item.get("asset_id", "")
|
||||
if aid:
|
||||
st = float(item.get("start_time", 0.0))
|
||||
record_used_segments(db, aid, st, st + float(item.get("duration", 0.0)), new_plan.id)
|
||||
|
||||
self.replace_all_clips_transactional(new_plan.id, clips_data)
|
||||
logger.info(
|
||||
"变体独立选片完成: source=%s new=%s clips=%d assets=%d",
|
||||
source_plan_id,
|
||||
new_plan.id,
|
||||
len(clips_data),
|
||||
len(pool_ids),
|
||||
)
|
||||
return new_plan
|
||||
|
||||
def clone_plan_for_variant(
|
||||
self,
|
||||
source_plan_id: str,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#3b82f6"/>
|
||||
<text x="32" y="44" font-size="34" text-anchor="middle">🦐</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 194 B |
@@ -139,6 +139,17 @@ export interface DirectUploadPrepareResult {
|
||||
* 旧后端不返回该字段,前端降级为无预建卡片的原有行为。
|
||||
*/
|
||||
asset_id?: string
|
||||
/**
|
||||
* 后端 file_hash 命中素材库已有相同文件时为 true,前端应跳过 transfer + complete 阶段
|
||||
* 直接按「去重命中」处理(不调 transfer、不调 complete、立即刷新素材列表)。
|
||||
* 旧后端不返回该字段,前端降级为走老流程。
|
||||
*/
|
||||
duplicated?: boolean
|
||||
/**
|
||||
* 与 duplicated 语义一致:true 表示跳过传输,前端据此短路。
|
||||
* 两个字段是同一语义的别名(后端可能只返回其一),前端任意为 true 即视为命中去重。
|
||||
*/
|
||||
skip_transfer?: boolean
|
||||
}
|
||||
|
||||
/** 直传完成确认返回 */
|
||||
|
||||
@@ -32,6 +32,8 @@ export const completeDirectUpload = async (data: {
|
||||
file_hash?: string
|
||||
/** 前端上传幂等 token(与 prepare 一致),同一次上传重发 complete 不重复建记录 */
|
||||
client_upload_id?: string
|
||||
/** 文件字节数;后端同名兜底去重需用它做大小校验,缺失(=0)时同名记录一律不判重 */
|
||||
file_size?: number
|
||||
}): Promise<DirectUploadCompleteResult> => {
|
||||
// complete 内含 OSS 存在性检查 + 建库 + 派单,放宽到 60s;
|
||||
// 超时不代表失败(记录可能已建成),调用方禁止超时后盲目重传整个文件
|
||||
@@ -126,7 +128,15 @@ export const prepareDirectUploadHandle = async (data: {
|
||||
/** 本次逻辑上传的幂等 token,prepare/complete 一致、重试复用 */
|
||||
clientUploadId?: string
|
||||
}): Promise<DirectUploadHandle> => {
|
||||
const project = await getOrCreateDefaultProject()
|
||||
// 默认项目初始化失败(项目列表接口异常/自动创建失败)给出独立、明确的提示,
|
||||
// 不与 prepare 的签名接口错误混在一起
|
||||
let project: Awaited<ReturnType<typeof getOrCreateDefaultProject>>
|
||||
try {
|
||||
project = await getOrCreateDefaultProject()
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : "网络异常"
|
||||
throw new Error(`初始化默认项目失败,无法开始上传:${reason}`)
|
||||
}
|
||||
|
||||
const prepared = await prepareDirectUpload({
|
||||
project_id: project.id,
|
||||
@@ -148,6 +158,8 @@ export const prepareDirectUploadHandle = async (data: {
|
||||
storage_key: prepared.storage_key,
|
||||
file_hash: data.fileHash,
|
||||
client_upload_id: data.clientUploadId,
|
||||
// 透传文件字节数:后端同名兜底去重依赖大小校验,缺省会导致同名新视频被误判重复
|
||||
file_size: data.file.size,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -171,6 +183,16 @@ export const uploadAssetDirect = async (data: {
|
||||
fileHash,
|
||||
clientUploadId,
|
||||
})
|
||||
// prepare 阶段后端 file_hash 命中素材库已有相同文件:跳过 transfer + complete
|
||||
if (handle.prepared.skip_transfer || handle.prepared.duplicated) {
|
||||
return {
|
||||
storage_key: handle.prepared.storage_key,
|
||||
ingest_job_id: "",
|
||||
url: "",
|
||||
duplicated: true,
|
||||
asset_id: handle.prepared.asset_id,
|
||||
}
|
||||
}
|
||||
await handle.transfer(data.onProgress)
|
||||
return handle.complete()
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
* 重试复用同一 ID,重新入队才生成新 ID)
|
||||
*/
|
||||
|
||||
/** 大文件抽样阈值:超过此大小只哈希头尾片段,避免上传前长时间卡 UI */
|
||||
export const HASH_FULL_READ_LIMIT = 256 * 1024 * 1024 // 256MB
|
||||
/** 抽样读取的头尾片段大小(各 8MB) */
|
||||
export const HASH_SAMPLE_CHUNK = 8 * 1024 * 1024
|
||||
/** 全量哈希阈值:≤64MB 全量读入计算;超过即走头尾抽样,避免 100~256MB 视频被整文件读进内存卡死页面 */
|
||||
export const HASH_FULL_READ_LIMIT = 64 * 1024 * 1024 // 64MB
|
||||
/** 抽样读取的头尾片段大小(各 16MB) */
|
||||
export const HASH_SAMPLE_CHUNK = 16 * 1024 * 1024
|
||||
|
||||
/** 计算指纹时,文件在队列中已存在的状态(已失败的可以重试,不算重复) */
|
||||
export type DedupExcludeStatus = "error" | "done"
|
||||
@@ -95,10 +95,10 @@ function toHex(buffer: ArrayBuffer): string {
|
||||
|
||||
/**
|
||||
* 计算文件内容 SHA-256(hex,64 字符,与后端 file_hash 字段长度一致)。
|
||||
* - ≤256MB:全量哈希,内容一致必然一致
|
||||
* - >256MB:哈希「头部 8MB + 尾部 8MB + 文件大小」,视频素材体积大、
|
||||
* - ≤64MB:全量哈希,内容一致必然一致
|
||||
* - >64MB:哈希「头部 16MB + 尾部 16MB + 文件大小」,视频素材体积大、
|
||||
* 头部含 moov 元数据、尾部含 mdat 结尾,抽样碰撞概率可忽略,
|
||||
* 且避免上传前对 2GB 文件全量读取造成长时间卡顿
|
||||
* 且避免 100~256MB 视频被整文件读进内存导致页面卡死/崩溃
|
||||
*
|
||||
* 运行环境不支持 crypto.subtle(非安全上下文/老浏览器)时返回空字符串,
|
||||
* 调用方据此降级为不传 hash(后端仍有幂等 token + 同文件名兜底去重)。
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 微信扫码登录 WxLogin JS-SDK 动态加载与授权参数解析
|
||||
*
|
||||
* 微信官网嵌入式二维码方案:页面引入 https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js
|
||||
* 后挂载全局 window.WxLogin,new WxLogin({...}) 会在指定容器内渲染二维码 iframe。
|
||||
* 本模块负责:动态加载该脚本(带超时/失败检测)、从后端返回的 auth_url 中解析
|
||||
* WxLogin 所需的 appid / redirect_uri / state。
|
||||
*/
|
||||
|
||||
const WX_LOGIN_SRC = "https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js"
|
||||
/** 脚本加载超时(毫秒):超时视为加载失败,调用方回退整页跳转 */
|
||||
const WX_LOGIN_LOAD_TIMEOUT = 8000
|
||||
|
||||
/** WxLogin 构造参数(微信官方字段,保持原名) */
|
||||
export interface WxLoginOptions {
|
||||
/** 是否内嵌二维码(回调在 iframe 内完成) */
|
||||
self_redirect: boolean
|
||||
/** 二维码容器元素 id */
|
||||
id: string
|
||||
/** 微信开放平台 AppID */
|
||||
appid: string
|
||||
/** 应用授权作用域,网站应用固定 snsapi_login */
|
||||
scope: "snsapi_login"
|
||||
/** 回调地址(需与微信开放平台配置一致,WxLogin 内部会 encodeURIComponent) */
|
||||
redirect_uri: string
|
||||
/** 防 CSRF 随机串,由后端 state store 生成并在回调时一次性消费 */
|
||||
state: string
|
||||
/** 二维码样式:black / white */
|
||||
style?: "black" | "white"
|
||||
/** 自定义样式链接(可选) */
|
||||
href?: string
|
||||
}
|
||||
|
||||
/** 微信脚本挂载到 window 上的全局构造函数类型 */
|
||||
export interface WxLoginConstructor {
|
||||
new (options: WxLoginOptions): unknown
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
WxLogin?: WxLoginConstructor
|
||||
}
|
||||
}
|
||||
|
||||
let loadPromise: Promise<WxLoginConstructor> | null = null
|
||||
|
||||
/**
|
||||
* 动态加载微信 WxLogin JS(单例:并发调用复用同一个 promise)。
|
||||
* 加载失败或超时会 reject,调用方应回退到整页跳转授权方式。
|
||||
*/
|
||||
export function loadWxLoginScript(): Promise<WxLoginConstructor> {
|
||||
if (window.WxLogin) return Promise.resolve(window.WxLogin)
|
||||
if (loadPromise) return loadPromise
|
||||
|
||||
loadPromise = new Promise<WxLoginConstructor>((resolve, reject) => {
|
||||
const script = document.createElement("script")
|
||||
script.src = WX_LOGIN_SRC
|
||||
script.async = true
|
||||
script.onload = () => {
|
||||
if (window.WxLogin) {
|
||||
resolve(window.WxLogin)
|
||||
} else {
|
||||
loadPromise = null
|
||||
reject(new Error("微信登录脚本加载完成但 WxLogin 未挂载"))
|
||||
}
|
||||
}
|
||||
script.onerror = () => {
|
||||
loadPromise = null
|
||||
script.remove()
|
||||
reject(new Error("微信登录脚本加载失败"))
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
|
||||
// 超时兜底:部分网络环境下脚本既不 onload 也不 onerror
|
||||
window.setTimeout(() => {
|
||||
if (window.WxLogin) {
|
||||
resolve(window.WxLogin)
|
||||
return
|
||||
}
|
||||
loadPromise = null
|
||||
script.remove()
|
||||
reject(new Error("微信登录脚本加载超时"))
|
||||
}, WX_LOGIN_LOAD_TIMEOUT)
|
||||
})
|
||||
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
/** 从微信授权链接 query 中解析出的 WxLogin 所需参数 */
|
||||
export interface ParsedWxAuthParams {
|
||||
appid: string
|
||||
/** 已 URL 解码的回调地址(传给 WxLogin 时由其内部再次编码) */
|
||||
redirect_uri: string
|
||||
state: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 从后端返回的微信授权链接(https://open.weixin.qq.com/connect/qrconnect?appid=...&redirect_uri=...&state=...)
|
||||
* 中解析 appid / redirect_uri / state。解析失败时返回 null,由调用方回退整页跳转。
|
||||
*/
|
||||
export function parseWxAuthUrl(authUrl: string, stateFallback?: string): ParsedWxAuthParams | null {
|
||||
try {
|
||||
const url = new URL(authUrl)
|
||||
const appid = url.searchParams.get("appid")
|
||||
const redirectUri = url.searchParams.get("redirect_uri")
|
||||
const state = url.searchParams.get("state") || stateFallback || ""
|
||||
if (!appid || !redirectUri || !state) return null
|
||||
return { appid, redirect_uri: redirectUri, state }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 统一错误信息提取
|
||||
* 把 axios 错误(后端 detail / FastAPI 校验错误 / HTTP 状态码)、XHR/OSS 错误、
|
||||
* 网络/超时错误、普通 Error 统一转成「可直接展示给用户」的中文信息。
|
||||
*
|
||||
* 与 api/client.ts 响应拦截器的提示口径保持一致;拦截器负责全局 toast,
|
||||
* 页面/队列卡片用本工具把真实原因展示在持久位置(回调页、失败卡片等)。
|
||||
*/
|
||||
import type { AxiosError } from "axios"
|
||||
|
||||
/** 后端错误响应体可能出现的字段(FastAPI:detail;历史接口:message/msg) */
|
||||
interface ErrorBody {
|
||||
detail?: unknown
|
||||
message?: unknown
|
||||
msg?: unknown
|
||||
}
|
||||
|
||||
/** FastAPI 422 校验错误单项 */
|
||||
interface ValidationItem {
|
||||
loc?: (string | number)[]
|
||||
msg?: string
|
||||
}
|
||||
|
||||
/** 从后端响应体提取人类可读信息(detail 可能是字符串、对象、422 数组) */
|
||||
function extractBodyMessage(data: unknown): string {
|
||||
if (!data || typeof data !== "object") return ""
|
||||
const body = data as ErrorBody
|
||||
|
||||
const walk = (val: unknown): string => {
|
||||
if (typeof val === "string") return val
|
||||
if (Array.isArray(val)) {
|
||||
// FastAPI 422: [{loc, msg, type}, ...] → 取每条 msg 拼接
|
||||
const parts = val
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item
|
||||
if (item && typeof item === "object") {
|
||||
const v = item as ValidationItem
|
||||
if (typeof v.msg === "string") {
|
||||
const field = Array.isArray(v.loc) ? v.loc.filter((x) => x !== "body").join(".") : ""
|
||||
return field ? `${field}: ${v.msg}` : v.msg
|
||||
}
|
||||
return walk(item)
|
||||
}
|
||||
return ""
|
||||
})
|
||||
.filter(Boolean)
|
||||
return parts.join(";")
|
||||
}
|
||||
if (val && typeof val === "object") {
|
||||
const obj = val as Record<string, unknown>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.msg === "string") return obj.msg
|
||||
if (typeof obj.detail === "string") return obj.detail
|
||||
if (obj.message && typeof obj.message === "object") return walk(obj.message)
|
||||
if (obj.msg && typeof obj.msg === "object") return walk(obj.msg)
|
||||
try {
|
||||
return JSON.stringify(val)
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
return walk(body.detail) || walk(body.message) || walk(body.msg)
|
||||
}
|
||||
|
||||
/** 无响应体时按 HTTP 状态码给出兜底提示(与 client.ts 拦截器口径一致) */
|
||||
function statusFallback(status: number): string {
|
||||
switch (status) {
|
||||
case 400:
|
||||
return "请求参数有误(HTTP 400)"
|
||||
case 401:
|
||||
return "登录状态已失效,请重新登录(HTTP 401)"
|
||||
case 403:
|
||||
return "没有权限执行该操作(HTTP 403)"
|
||||
case 404:
|
||||
return "请求的资源不存在(HTTP 404)"
|
||||
case 409:
|
||||
return "操作冲突,资源状态已变化(HTTP 409)"
|
||||
case 413:
|
||||
return "文件过大,请缩小后重试(HTTP 413)"
|
||||
case 415:
|
||||
return "不支持的文件格式(HTTP 415)"
|
||||
case 429:
|
||||
return "操作过于频繁,请稍后再试(HTTP 429)"
|
||||
case 503:
|
||||
return "服务暂不可用,请稍后再试(HTTP 503)"
|
||||
default:
|
||||
if (status >= 500) return `服务器繁忙,请稍后再试(HTTP ${status})`
|
||||
return `请求失败(HTTP ${status})`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从任意抛出值提取可展示的错误信息。
|
||||
* @param fallback 全部提取失败时的兜底文案
|
||||
*/
|
||||
export function getErrorMessage(err: unknown, fallback = "操作失败,请稍后重试"): string {
|
||||
if (!err) return fallback
|
||||
|
||||
// axios 错误(后端 JSON 响应 / HTTP 错误状态)
|
||||
const ax = err as AxiosError<ErrorBody>
|
||||
if (ax.isAxiosError || (typeof ax === "object" && "response" in (ax as object))) {
|
||||
// 超时
|
||||
if (ax.code === "ECONNABORTED" || /timeout/i.test(ax.message || "")) {
|
||||
return "请求超时,请检查网络后重试"
|
||||
}
|
||||
const resp = ax.response
|
||||
if (resp) {
|
||||
const bodyMsg = extractBodyMessage(resp.data)
|
||||
if (bodyMsg) return bodyMsg
|
||||
return statusFallback(resp.status)
|
||||
}
|
||||
// 请求已发出但无响应(断网/CORS/DNS)
|
||||
if (ax.request) return "网络连接异常,请检查网络设置"
|
||||
return ax.message || fallback
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
// XHR 直传 OSS 失败等场景自带详细 message(含 HTTP 状态 + OSS Code/Message)
|
||||
if (err.message) return err.message
|
||||
}
|
||||
if (typeof err === "string") return err
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** client.ts 拦截器是否已对该错误弹过全局 toast(__msgShown 标记) */
|
||||
export function isErrorMsgShown(err: unknown): boolean {
|
||||
return Boolean((err as { __msgShown?: boolean } | null)?.__msgShown)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 批量变体剪辑计划 API(#1744)
|
||||
*
|
||||
* 批量预览时向后端申请 N 个变体的「独立剪辑计划片段」:
|
||||
* - 变体 0 保留源 plan(用户在编辑器/智能选片产出的片段,含标题样式编辑结果);
|
||||
* - 变体 1..N-1 由后端 reselect_plan_for_variant 完整重跑单视频选片流程
|
||||
* (素材洗牌 + main 片段顺序洗牌 + 镜头/起点随机 + 跨变体 20% 区间避让 +
|
||||
* 素材使用区间写回 metadata),与正式批量生成 POST /generation/tasks?count=N
|
||||
* 使用同一套选片逻辑;
|
||||
* - 正式生成时把 variant_plan_ids 原样回传,后端直接关联这些 plan 渲染,
|
||||
* 不再重新选片 —— 预览所见即成片。
|
||||
*
|
||||
* 该接口只做选片/建 plan(秒级),不触发视频渲染,无渲染成本。
|
||||
* 后端端点未上线(404)或选片失败(素材不足等)时前端降级为本地 variantSeed
|
||||
* 模拟预览,不阻塞用户流程。
|
||||
*/
|
||||
import apiClient from "../client"
|
||||
import type { EditPlanClip } from "../template-editor"
|
||||
|
||||
/** 批量变体计划请求体 */
|
||||
export interface BatchVariantPlansRequest {
|
||||
template_id: string
|
||||
/** 本批次素材池(手动选择或智能匹配结果) */
|
||||
asset_ids: string[]
|
||||
/** 变体数量(≥1);=1 时只返回源 plan 片段 */
|
||||
count: number
|
||||
/** 源剪辑计划 ID:优先取预览/草稿关联的 plan;不传由后端按 template_id+user 兜底最新 plan */
|
||||
source_edit_plan_id?: string
|
||||
}
|
||||
|
||||
/** 单个变体的计划片段 */
|
||||
export interface VariantPlan {
|
||||
/** 变体序号,从 0 开始 */
|
||||
variant_index: number
|
||||
/** 该变体关联的剪辑计划 ID(正式生成时回传,实现预览即成片) */
|
||||
plan_id: string
|
||||
/** 该变体的真实片段(顺序/素材/起点与正式成片一致) */
|
||||
clips: EditPlanClip[]
|
||||
}
|
||||
|
||||
/** 批量变体计划响应 */
|
||||
export interface BatchVariantPlansResponse {
|
||||
items: VariantPlan[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建批量变体剪辑计划并返回各变体片段。
|
||||
*
|
||||
* 注意:端点 404(后端未上线)/ 400(素材不足)等失败由调用方 catch 后降级,
|
||||
* 不要抛 unhandled rejection。
|
||||
*/
|
||||
export async function createBatchVariantPlans(
|
||||
params: BatchVariantPlansRequest,
|
||||
): Promise<BatchVariantPlansResponse> {
|
||||
const response = await apiClient.post<BatchVariantPlansResponse>(
|
||||
"/generation/variant-plans",
|
||||
params,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -100,6 +100,12 @@ export interface CreateGenerationTaskRequest {
|
||||
voice_library_ids?: string[]
|
||||
/** 各变体独立封面URL:长度1=共用,长度=count=独立,空数组=回退 cover_url */
|
||||
cover_urls?: string[]
|
||||
/**
|
||||
* 批量变体剪辑计划 ID(#1744):预览阶段后端独立选片产出的 plan id 列表
|
||||
* (按变体全量索引,长度=previewCount)。正式生成回传后后端直接关联这些
|
||||
* plan 渲染、不再重新选片,保证预览所见即成片。后端未支持时忽略该字段。
|
||||
*/
|
||||
variant_plan_ids?: string[]
|
||||
}
|
||||
|
||||
/** 单个生成任务详情(对齐后端 GenerationTaskResponse) */
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
.xx-wechat-qr-modal {
|
||||
position: relative;
|
||||
padding: 8px 0 4px;
|
||||
min-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 常驻二维码容器(WxLogin 渲染目标) */
|
||||
.xx-wechat-qr-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
/* loading / error 遮罩层,覆盖在二维码容器之上 */
|
||||
.xx-wechat-qr-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-overlay p {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-container iframe {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-tip {
|
||||
margin: 12px 0 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-error {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-error-msg {
|
||||
color: #ef4444;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 16px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-error-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-wechat-qr-fallback {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary-color, #3b82f6);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* 微信扫码二维码弹窗(登录 / 绑定复用)
|
||||
*
|
||||
* 微信官方嵌入式二维码方案:弹窗内用 new WxLogin({ self_redirect: true }) 渲染二维码,
|
||||
* 扫码后微信重定向到本站回调页(在二维码 iframe 内加载),回调页通过 postMessage
|
||||
* 把成功/失败结果通知本弹窗(消息协议见 ./messages)。
|
||||
*
|
||||
* 兜底:获取授权链接成功但 WxLogin JS 加载失败/超时时,自动回退整页跳转授权
|
||||
* (与旧流程一致);获取授权链接本身失败时在弹窗内展示错误并提供重试。
|
||||
*/
|
||||
import React, { useEffect, useRef, useState } from "react"
|
||||
import { Spin } from "antd"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import Button from "@/components/ui/Button"
|
||||
import {
|
||||
getWechatAuthUrl,
|
||||
getWechatBindUrl,
|
||||
getCurrentUser,
|
||||
normalizeUser,
|
||||
type User,
|
||||
} from "@/api/auth"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { scheduleProactiveRefresh } from "@/api/auth/tokenRefresh"
|
||||
import { getErrorMessage } from "@/api/errors"
|
||||
import { loadWxLoginScript, parseWxAuthUrl } from "@/api/auth/wxLogin"
|
||||
import { isWechatQrMessage, type WechatQrScene } from "./messages"
|
||||
import "./WechatQrModal.css"
|
||||
|
||||
export interface WechatQrModalProps {
|
||||
open: boolean
|
||||
scene: WechatQrScene
|
||||
onClose: () => void
|
||||
/** 登录场景成功回调(needOnboarding=true 时调用方应跳昵称引导页) */
|
||||
onLoginSuccess?: (needOnboarding: boolean) => void
|
||||
/** 绑定场景成功回调(调用方刷新用户信息/提示) */
|
||||
onBindSuccess?: () => void
|
||||
}
|
||||
|
||||
type QrStatus = "loading" | "qrcode" | "error"
|
||||
|
||||
const CONTAINER_ID: Record<WechatQrScene, string> = {
|
||||
login: "wechat-qr-login-container",
|
||||
bind: "wechat-qr-bind-container",
|
||||
}
|
||||
|
||||
const STATE_STORAGE_KEY: Record<WechatQrScene, string> = {
|
||||
login: "wechat_state",
|
||||
bind: "wechat_bind_state",
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待二维码容器挂载到 DOM。antd Modal 内容通过 portal 渲染且带进场动画,
|
||||
* 父组件 effect 首次执行时容器可能尚未出现在 document 中。
|
||||
*/
|
||||
function waitForContainer(id: string, timeoutMs = 3000): Promise<HTMLElement | null> {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now()
|
||||
const check = () => {
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
resolve(el)
|
||||
return
|
||||
}
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
setTimeout(check, 50)
|
||||
}
|
||||
check()
|
||||
})
|
||||
}
|
||||
|
||||
const WechatQrModal: React.FC<WechatQrModalProps> = ({
|
||||
open,
|
||||
scene,
|
||||
onClose,
|
||||
onLoginSuccess,
|
||||
onBindSuccess,
|
||||
}) => {
|
||||
const setAuth = useAuthStore((state) => state.setAuth)
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
const [status, setStatus] = useState<QrStatus>("loading")
|
||||
const [errorMsg, setErrorMsg] = useState("")
|
||||
/** 刷新二维码计数:变化时重新请求授权链接并重渲染 */
|
||||
const [renderSeq, setRenderSeq] = useState(0)
|
||||
/** 最新授权链接,用于"整页打开"兜底 */
|
||||
const authUrlRef = useRef<string | null>(null)
|
||||
|
||||
const isLogin = scene === "login"
|
||||
|
||||
// 初始化:获取授权链接 → 加载 WxLogin JS → 内嵌渲染二维码
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
authUrlRef.current = null
|
||||
setStatus("loading")
|
||||
setErrorMsg("")
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
const fetchUrl = isLogin ? getWechatAuthUrl : getWechatBindUrl
|
||||
const result = await fetchUrl()
|
||||
if (cancelled) return
|
||||
// 写 state(整页跳转兜底路径的回调页也会清理它)
|
||||
localStorage.setItem(STATE_STORAGE_KEY[scene], result.state)
|
||||
authUrlRef.current = result.auth_url
|
||||
|
||||
const params = parseWxAuthUrl(result.auth_url, result.state)
|
||||
if (!params) {
|
||||
// 授权链接格式异常:直接整页跳转,由微信侧/回调页兜底
|
||||
window.location.href = result.auth_url
|
||||
return
|
||||
}
|
||||
|
||||
const WxLogin = await loadWxLoginScript()
|
||||
if (cancelled) return
|
||||
// 等 Modal portal 中的容器挂载完成
|
||||
const container = await waitForContainer(CONTAINER_ID[scene])
|
||||
if (cancelled) return
|
||||
if (!container) {
|
||||
window.location.href = result.auth_url
|
||||
return
|
||||
}
|
||||
container.innerHTML = ""
|
||||
new WxLogin({
|
||||
self_redirect: true,
|
||||
id: CONTAINER_ID[scene],
|
||||
appid: params.appid,
|
||||
scope: "snsapi_login",
|
||||
redirect_uri: params.redirect_uri,
|
||||
state: params.state,
|
||||
style: "black",
|
||||
})
|
||||
if (!cancelled) setStatus("qrcode")
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
if (authUrlRef.current) {
|
||||
// 授权链接已拿到但二维码脚本加载失败/超时:回退整页跳转
|
||||
window.location.href = authUrlRef.current
|
||||
return
|
||||
}
|
||||
// 授权链接接口本身失败:弹窗内展示真实原因,允许重试
|
||||
setErrorMsg(getErrorMessage(err, "微信服务暂不可用,请稍后重试"))
|
||||
setStatus("error")
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, scene, isLogin, renderSeq])
|
||||
|
||||
// 监听 iframe 内回调页 postMessage 回来的扫码结果
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
// 只接受同源消息
|
||||
if (event.origin !== window.location.origin) return
|
||||
if (!isWechatQrMessage(event.data, scene)) return
|
||||
const msg = event.data
|
||||
|
||||
if (msg.success) {
|
||||
if (isLogin) {
|
||||
// iframe 内回调页已把 token 写入 localStorage(同源共享),
|
||||
// 父窗口同步内存登录态后交给调用方跳转
|
||||
try {
|
||||
const userData = await getCurrentUser()
|
||||
const user = normalizeUser(userData) as User
|
||||
setAuth(
|
||||
user,
|
||||
localStorage.getItem("access_token") || "",
|
||||
localStorage.getItem("refresh_token"),
|
||||
)
|
||||
scheduleProactiveRefresh()
|
||||
} catch {
|
||||
// token 已持久化,即使这里失败路由守卫/刷新也能恢复登录态
|
||||
}
|
||||
onLoginSuccess?.(msg.payload?.needOnboarding ?? false)
|
||||
} else {
|
||||
try {
|
||||
const userData = await getCurrentUser()
|
||||
setUser(normalizeUser(userData) as User)
|
||||
} catch {
|
||||
// 绑定结果以后端为准,调用方 invalidateQueries 会兜底刷新
|
||||
}
|
||||
onBindSuccess?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 失败:弹窗内展示回调页透传的真实原因,提供刷新/整页跳转
|
||||
setErrorMsg(msg.detail || "微信授权失败,请重试")
|
||||
setStatus("error")
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
return () => window.removeEventListener("message", handleMessage)
|
||||
}, [open, scene, isLogin, onLoginSuccess, onBindSuccess, setAuth, setUser])
|
||||
|
||||
const handleRefresh = () => setRenderSeq((seq) => seq + 1)
|
||||
|
||||
const handleFullPageRedirect = () => {
|
||||
if (authUrlRef.current) {
|
||||
window.location.href = authUrlRef.current
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isLogin ? "微信扫码登录" : "绑定微信"}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={380}
|
||||
maskClosable={false}
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="xx-wechat-qr-modal">
|
||||
{/* 二维码容器常驻:WxLogin 在 loading 阶段就会把 iframe 渲染进来,
|
||||
不能按 status 条件渲染,否则 effect 里永远找不到容器 */}
|
||||
<div
|
||||
id={CONTAINER_ID[scene]}
|
||||
className="xx-wechat-qr-container"
|
||||
style={{ visibility: status === "qrcode" ? "visible" : "hidden" }}
|
||||
/>
|
||||
|
||||
{status === "loading" && (
|
||||
<div className="xx-wechat-qr-overlay">
|
||||
<Spin size="large" />
|
||||
<p>正在生成微信二维码...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "qrcode" && (
|
||||
<p className="xx-wechat-qr-tip">请使用微信扫描二维码{isLogin ? "登录" : "绑定账号"}</p>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="xx-wechat-qr-overlay xx-wechat-qr-error">
|
||||
<p className="xx-wechat-qr-error-msg">{errorMsg}</p>
|
||||
<div className="xx-wechat-qr-error-actions">
|
||||
<Button buttonType="primary" buttonSize="md" onClick={handleRefresh}>
|
||||
刷新二维码
|
||||
</Button>
|
||||
{authUrlRef.current && (
|
||||
<button
|
||||
type="button"
|
||||
className="xx-wechat-qr-fallback"
|
||||
onClick={handleFullPageRedirect}
|
||||
>
|
||||
使用整页方式打开
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default WechatQrModal
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 微信扫码弹窗与 iframe 内回调页之间的 postMessage 消息协议
|
||||
*
|
||||
* 流程:弹窗内 WxLogin(self_redirect:true) 渲染的二维码 iframe 扫码后,
|
||||
* 微信重定向到本站回调页(同源,在 iframe 内加载);回调页完成换 token/绑定后,
|
||||
* 通过 window.parent.postMessage 把结果通知弹窗,弹窗负责关闭/展示错误/同步登录态。
|
||||
*/
|
||||
|
||||
/** 扫码场景:登录 / 绑定 */
|
||||
export type WechatQrScene = "login" | "bind"
|
||||
|
||||
export interface WechatQrSuccessPayload {
|
||||
/** 登录场景:是否需要昵称引导(新用户或资料未完善) */
|
||||
needOnboarding?: boolean
|
||||
}
|
||||
|
||||
export interface WechatQrMessageData {
|
||||
/** 固定协议标识,父窗口只认该 source */
|
||||
source: "xiaoxia-wechat-qr"
|
||||
/** 场景,需与弹窗发起时一致(login/bind),父窗口据此过滤 */
|
||||
scene: WechatQrScene
|
||||
/** 成功 / 失败 */
|
||||
success: boolean
|
||||
/** 失败时的真实原因(已在回调页拼好,含后端 detail) */
|
||||
detail?: string
|
||||
payload?: WechatQrSuccessPayload
|
||||
}
|
||||
|
||||
export const WECHAT_QR_MESSAGE_SOURCE = "xiaoxia-wechat-qr"
|
||||
|
||||
/** 判断收到的 message 是否为本协议消息(且场景匹配) */
|
||||
export function isWechatQrMessage(
|
||||
data: unknown,
|
||||
scene: WechatQrScene,
|
||||
): data is WechatQrMessageData {
|
||||
if (!data || typeof data !== "object") return false
|
||||
const msg = data as Partial<WechatQrMessageData>
|
||||
return msg.source === WECHAT_QR_MESSAGE_SOURCE && msg.scene === scene
|
||||
}
|
||||
|
||||
/** 当前页面是否运行在 iframe(弹窗内嵌二维码)中 */
|
||||
export function isInIframe(): boolean {
|
||||
try {
|
||||
return window.parent !== window
|
||||
} catch {
|
||||
// 跨域访问 window.parent 可能抛异常,按非 iframe 处理
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* iframe 内回调页向父窗口上报扫码结果。同源回调页加载,targetOrigin 限定本站 origin。
|
||||
*/
|
||||
export function postWechatQrResult(
|
||||
scene: WechatQrScene,
|
||||
success: boolean,
|
||||
options?: { detail?: string; needOnboarding?: boolean },
|
||||
): void {
|
||||
if (!isInIframe()) return
|
||||
const data: WechatQrMessageData = {
|
||||
source: WECHAT_QR_MESSAGE_SOURCE,
|
||||
scene,
|
||||
success,
|
||||
detail: options?.detail,
|
||||
payload:
|
||||
success && options?.needOnboarding !== undefined
|
||||
? { needOnboarding: options.needOnboarding }
|
||||
: undefined,
|
||||
}
|
||||
window.parent.postMessage(data, window.location.origin)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 全局错误边界:专门兜底"发版后旧标签页懒加载 chunk 失效"导致的白屏,
|
||||
* 同时兜住页面级渲染崩溃,避免任何未捕获错误导致整页白屏无反馈。
|
||||
*
|
||||
* 捕获到 ChunkLoadError / Failed to fetch dynamically imported module:
|
||||
* 1. 首次:自动整页刷新一次(sessionStorage 标记,刷新后 index.html 重新拉取,
|
||||
* 拿到新 chunk 引用,白屏自愈)
|
||||
* 2. 刷新后仍失败(标记未过期):不再自动刷新,显示"系统已更新,请点击刷新"
|
||||
* 兜底界面,由用户手动点击
|
||||
*
|
||||
* 其他非 chunk 错误:显示通用错误页 + "返回首页"按钮(跳首页而非刷新当前 URL,
|
||||
* 避免刷新后再次命中同一路由崩溃形成死循环)。
|
||||
*/
|
||||
import React from "react"
|
||||
import { Button, Result } from "antd"
|
||||
import {
|
||||
getChunkReloadedAt,
|
||||
goHomeRecover,
|
||||
isChunkLoadError,
|
||||
reloadForChunkError,
|
||||
} from "@/utils/chunkLoadError"
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null
|
||||
isChunkError: boolean
|
||||
/** 捕获错误时是否已经自动刷新过(决定显示自动刷新中还是手动兜底) */
|
||||
alreadyReloaded: boolean
|
||||
}
|
||||
|
||||
class ChunkErrorBoundary extends React.Component<Props, State> {
|
||||
state: State = { error: null, isChunkError: false, alreadyReloaded: false }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
const chunk = isChunkLoadError(error)
|
||||
return {
|
||||
error,
|
||||
isChunkError: chunk,
|
||||
alreadyReloaded: chunk ? getChunkReloadedAt() !== null : false,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
// 仅 chunk 错误且本次会话没自动刷新过 → 打标记并整页刷新(自愈)
|
||||
if (isChunkLoadError(error) && getChunkReloadedAt() === null) {
|
||||
reloadForChunkError()
|
||||
}
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
const { error, isChunkError, alreadyReloaded } = this.state
|
||||
if (!error) return this.props.children
|
||||
|
||||
if (isChunkError && !alreadyReloaded) {
|
||||
// 已打标记、componentDidCatch 里已触发 reload;极短瞬间展示加载中
|
||||
return (
|
||||
<Result status="info" title="系统正在更新" subTitle="检测到新版本,正在自动刷新页面…" />
|
||||
)
|
||||
}
|
||||
|
||||
// 手动兜底统一跳首页(整页导航):chunk 失效时脱离旧 chunk 引用;
|
||||
// 业务崩溃时绕开当前报错路由,避免刷新-再崩死循环
|
||||
return (
|
||||
<Result
|
||||
status="warning"
|
||||
title={isChunkError ? "系统已更新" : "页面出现异常"}
|
||||
subTitle={
|
||||
isChunkError
|
||||
? "检测到新版本,请点击下方按钮回到首页加载最新内容。"
|
||||
: "页面加载遇到问题,点击返回首页通常可以恢复,未保存的内容可能丢失。"
|
||||
}
|
||||
extra={
|
||||
<Button type="primary" onClick={goHomeRecover}>
|
||||
{isChunkError ? "刷新并返回首页" : "返回首页"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default ChunkErrorBoundary
|
||||
@@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { ConfigProvider, App as AntApp } from "antd"
|
||||
import zhCN from "antd/locale/zh_CN"
|
||||
import router from "./router"
|
||||
import ChunkErrorBoundary from "./components/common/ChunkErrorBoundary"
|
||||
import { scheduleProactiveRefresh } from "./api/auth/tokenRefresh"
|
||||
|
||||
// 应用启动时,如果用户已登录,立即调度主动 token 刷新
|
||||
@@ -99,7 +100,9 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfigProvider locale={zhCN} theme={theme}>
|
||||
<AntApp>
|
||||
<RouterProvider router={router} />
|
||||
<ChunkErrorBoundary>
|
||||
<RouterProvider router={router} />
|
||||
</ChunkErrorBoundary>
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -831,6 +831,20 @@
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.xx-upload-queue-error-detail {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #ef4444;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.xx-upload-queue-error-hint {
|
||||
margin-top: 2px;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.xx-upload-queue-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
ReloadOutlined,
|
||||
CloseOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { UploadItem } from "../hooks/useAssetUpload"
|
||||
import type { UploadItem, UploadFailStage } from "../hooks/useAssetUpload"
|
||||
import { COMPLETE_RETRY_HINT } from "../hooks/useAssetUpload"
|
||||
|
||||
export interface UploadQueuePanelProps {
|
||||
items: UploadItem[]
|
||||
@@ -29,6 +30,13 @@ const STATUS_TEXT: Record<UploadItem["status"], string> = {
|
||||
error: "上传失败",
|
||||
}
|
||||
|
||||
/** 失败阶段中文名:让用户一眼看到失败发生在哪一步 */
|
||||
const FAIL_STAGE_TEXT: Record<UploadFailStage, string> = {
|
||||
prepare: "准备上传阶段",
|
||||
transfer: "文件传输阶段",
|
||||
complete: "确认入库阶段",
|
||||
}
|
||||
|
||||
const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
|
||||
items,
|
||||
onRetry,
|
||||
@@ -82,8 +90,23 @@ const UploadQueuePanel: React.FC<UploadQueuePanelProps> = ({
|
||||
{it.duplicated ? "素材已存在,已跳过" : STATUS_TEXT[it.status]}
|
||||
{it.status === "preparing" && it.hint ? `(${it.hint})` : ""}
|
||||
{it.status === "uploading" ? ` ${it.progress}%` : ""}
|
||||
{it.status === "error" && it.error ? `:${it.error}` : ""}
|
||||
{it.status === "error" && it.failedStage
|
||||
? `(${FAIL_STAGE_TEXT[it.failedStage]})`
|
||||
: ""}
|
||||
</div>
|
||||
{it.status === "error" && it.error ? (
|
||||
<div className="xx-upload-queue-error-detail" title={it.error}>
|
||||
{it.error.split("\n").map((line, idx) =>
|
||||
line === COMPLETE_RETRY_HINT ? (
|
||||
<div key={idx} className="xx-upload-queue-error-hint">
|
||||
{line}
|
||||
</div>
|
||||
) : (
|
||||
<div key={idx}>{line}</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="xx-upload-queue-actions">
|
||||
{it.status === "error" && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { prepareDirectUploadHandle, type DirectUploadHandle } from "@/api/assets"
|
||||
import { getErrorMessage, isErrorMsgShown } from "@/api/errors"
|
||||
import { MAX_FILE_SIZE } from "../constants"
|
||||
import {
|
||||
computeFileHash,
|
||||
@@ -44,9 +45,15 @@ export interface UploadItem {
|
||||
/** 批量直传最大并发数,避免多文件瓜分上行带宽 */
|
||||
const MAX_CONCURRENT = 3
|
||||
|
||||
/** complete 阶段失败后的错误提示:素材可能已在服务器处理中,重试不会重新上传 */
|
||||
const COMPLETE_ERROR_HINT =
|
||||
"确认请求失败,素材可能已在服务器处理中;点重试将安全确认,不会重新上传文件"
|
||||
/** complete 阶段失败后的安全提示:素材可能已在后端建成,重试只重发 complete 幂等安全 */
|
||||
export const COMPLETE_RETRY_HINT = "素材可能已在服务器处理中,点重试将安全确认,不会重新上传文件"
|
||||
|
||||
/** 失败阶段中文名(toast 提示用,明确失败发生在哪一步) */
|
||||
const STAGE_LABEL: Record<UploadFailStage, string> = {
|
||||
prepare: "准备上传",
|
||||
transfer: "文件传输",
|
||||
complete: "确认入库",
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材批量上传 Hook
|
||||
@@ -134,6 +141,20 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
}))
|
||||
handlesRef.current.set(item.tempId, h)
|
||||
|
||||
// prepare 阶段后端 file_hash 命中素材库已有相同文件(skip_transfer / duplicated):
|
||||
// 立即标记 done、调一次 refreshList 让已存在素材立即显示,跳过 transfer + complete
|
||||
if (h.prepared.skip_transfer || h.prepared.duplicated) {
|
||||
updateItem(item.tempId, {
|
||||
status: "done",
|
||||
duplicated: true,
|
||||
assetId: h.prepared.asset_id,
|
||||
})
|
||||
handlesRef.current.delete(item.tempId)
|
||||
refreshList()
|
||||
message.info(`"${item.fileName}" 与素材库已有内容相同,已跳过`)
|
||||
return
|
||||
}
|
||||
|
||||
if (h.prepared.asset_id) {
|
||||
updateItem(item.tempId, {
|
||||
status: "uploading",
|
||||
@@ -165,20 +186,25 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
message.success(`"${item.fileName}" 上传完成,正在转码处理`)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const detail = err instanceof Error ? err.message : "上传失败"
|
||||
// 完整失败原因:HTTP 状态码 / OSS XML 的 Code+Message / 后端 detail,
|
||||
// 由 getErrorMessage 统一提取(OSS XHR 错误自带「OSS 直传失败: HTTP xxx ...」明细)
|
||||
const detail = getErrorMessage(err, "未知错误")
|
||||
console.error("[useAssetUpload] 上传失败:", item.fileName, stage, err)
|
||||
|
||||
if (stage === "complete") {
|
||||
// complete 失败(超时/5xx/网络):后端记录可能已建成,handle 保留供幂等重试;
|
||||
// 刷新列表让用户看到可能已创建的「处理中」素材,避免误以为没传上去而重复操作
|
||||
// 刷新列表让用户看到可能已创建的「处理中」素材,避免误以为没传上去而重复操作。
|
||||
// 卡片同时展示真实错误原因 + 安全重试提示(重试只重发 complete,不重新上传)
|
||||
refreshList()
|
||||
updateItem(item.tempId, {
|
||||
status: "error",
|
||||
failedStage: "complete",
|
||||
error: COMPLETE_ERROR_HINT,
|
||||
error: `${detail}\n${COMPLETE_RETRY_HINT}`,
|
||||
hint: undefined,
|
||||
})
|
||||
message.error(`"${item.fileName}" ${COMPLETE_ERROR_HINT}`)
|
||||
if (!isErrorMsgShown(err)) {
|
||||
message.error(`"${item.fileName}" 确认入库失败:${detail}`)
|
||||
}
|
||||
} else {
|
||||
// prepare / transfer 失败:后端尚无素材记录,可安全全量重跑
|
||||
handlesRef.current.delete(item.tempId)
|
||||
@@ -188,7 +214,11 @@ export function useAssetUpload({ effectiveLibId }: { effectiveLibId: string }) {
|
||||
error: detail,
|
||||
hint: undefined,
|
||||
})
|
||||
message.error(`"${item.fileName}" 上传失败:${detail}`)
|
||||
// 拦截器已对后端错误弹过 toast(含真实 detail)时不重复弹;
|
||||
// OSS XHR 直传错误不走 axios,必须在这里弹
|
||||
if (!isErrorMsgShown(err)) {
|
||||
message.error(`"${item.fileName}" ${STAGE_LABEL[stage]}失败:${detail}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,8 +5,8 @@ import React, { useState } from "react"
|
||||
import { Form, Input, Checkbox, message } from "antd"
|
||||
import { Link, useNavigate } from "react-router-dom"
|
||||
import { useLogin } from "@/hooks/useAuth"
|
||||
import { getWechatAuthUrl } from "@/api/auth"
|
||||
import Button from "@/components/ui/Button"
|
||||
import WechatQrModal from "@/components/auth/WechatQrModal"
|
||||
import "./Login.css"
|
||||
|
||||
interface LoginFormValues {
|
||||
@@ -19,7 +19,7 @@ const Login: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const loginMutation = useLogin()
|
||||
const [form] = Form.useForm()
|
||||
const [wechatLoading, setWechatLoading] = useState(false)
|
||||
const [wechatQrOpen, setWechatQrOpen] = useState(false)
|
||||
|
||||
const onFinish = async (values: LoginFormValues) => {
|
||||
try {
|
||||
@@ -35,27 +35,28 @@ const Login: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleWechatLogin = async () => {
|
||||
try {
|
||||
setWechatLoading(true)
|
||||
const result = await getWechatAuthUrl()
|
||||
// 保存 state 到 localStorage 用于回调时验证
|
||||
localStorage.setItem("wechat_state", result.state)
|
||||
// 记录登录前的来源页,登录成功后跳回
|
||||
const from = window.location.pathname + window.location.search
|
||||
if (from !== "/login" && from !== "/register") {
|
||||
localStorage.setItem("login_redirect", from)
|
||||
} else {
|
||||
localStorage.removeItem("login_redirect")
|
||||
}
|
||||
// 跳转到微信授权页
|
||||
window.location.href = result.auth_url
|
||||
} catch (error) {
|
||||
if (!(error as { __msgShown?: boolean })?.__msgShown)
|
||||
message.error("微信登录暂不可用,请稍后重试")
|
||||
} finally {
|
||||
setWechatLoading(false)
|
||||
const handleWechatLogin = () => {
|
||||
// 记录登录前的来源页,登录成功后(弹窗回调)跳回
|
||||
const from = window.location.pathname + window.location.search
|
||||
if (from !== "/login" && from !== "/register") {
|
||||
localStorage.setItem("login_redirect", from)
|
||||
} else {
|
||||
localStorage.removeItem("login_redirect")
|
||||
}
|
||||
setWechatQrOpen(true)
|
||||
// 弹窗打开期间按钮 disabled;WxLogin 脚本加载失败/超时时弹窗内会自动回退整页跳转
|
||||
}
|
||||
|
||||
// 弹窗扫码登录成功:登录态已由弹窗同步,按用户类型跳转
|
||||
const handleWechatQrSuccess = (needOnboarding: boolean) => {
|
||||
setWechatQrOpen(false)
|
||||
if (needOnboarding) {
|
||||
navigate("/welcome/wechat", { replace: true })
|
||||
return
|
||||
}
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -126,10 +127,10 @@ const Login: React.FC = () => {
|
||||
type="button"
|
||||
className="xx-btn-wechat"
|
||||
onClick={handleWechatLogin}
|
||||
disabled={wechatLoading}
|
||||
disabled={wechatQrOpen}
|
||||
>
|
||||
<span className="xx-wechat-icon">💬</span>
|
||||
{wechatLoading ? "加载中..." : "微信登录"}
|
||||
微信登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -137,6 +138,13 @@ const Login: React.FC = () => {
|
||||
还没有账号? <Link to="/register">立即注册</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WechatQrModal
|
||||
open={wechatQrOpen}
|
||||
scene="login"
|
||||
onClose={() => setWechatQrOpen(false)}
|
||||
onLoginSuccess={handleWechatQrSuccess}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,49 +1,70 @@
|
||||
/**
|
||||
* 微信绑定回调页(已登录用户在设置页发起"绑定微信"扫码后回到这里)
|
||||
* 用 code 调绑定接口把微信关联到当前账号,成功后回设置页
|
||||
*
|
||||
* 两种运行环境:
|
||||
* - 整页跳转授权(旧流程/兜底):本页整页加载,成功/失败后 navigate 回设置页
|
||||
* - 弹窗内嵌二维码(WxLogin self_redirect):本页在同源 iframe 内加载,
|
||||
* 结果通过 postMessage 通知父窗口弹窗,不做页面导航
|
||||
*/
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { useSearchParams, useNavigate } from "react-router-dom"
|
||||
import { Spin } from "antd"
|
||||
import { bindWechat, normalizeUser } from "@/api/auth"
|
||||
import { getErrorMessage } from "@/api/errors"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { isInIframe, postWechatQrResult } from "@/components/auth/WechatQrModal/messages"
|
||||
|
||||
const WechatBindCallback: React.FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const setUser = useAuthStore((state) => state.setUser)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const inIframe = isInIframe()
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams.get("code")
|
||||
const state = searchParams.get("state")
|
||||
|
||||
const fail = (message: string) => {
|
||||
if (inIframe) {
|
||||
// 弹窗模式:把真实原因上报父窗口在 Modal 内展示
|
||||
postWechatQrResult("bind", false, { detail: message })
|
||||
return
|
||||
}
|
||||
setError(message)
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
setError("无效的回调参数")
|
||||
fail("无效的回调参数,请回到设置页重新扫码绑定")
|
||||
return
|
||||
}
|
||||
|
||||
const handleBind = async () => {
|
||||
// state 校验:绑定场景由设置页生成并落库,前缀 bind:
|
||||
const savedState = localStorage.getItem("wechat_bind_state")
|
||||
if (!savedState || savedState !== state) {
|
||||
setError("安全校验失败,请重新绑定")
|
||||
return
|
||||
}
|
||||
// state 校验由后端 state store 一次性消费兜底(前端不再比对 localStorage,
|
||||
// 微信内打开/跨浏览器场景本地无 state 会误杀);清理绑定前写入的 state
|
||||
localStorage.removeItem("wechat_bind_state")
|
||||
|
||||
try {
|
||||
const result = await bindWechat(code, state)
|
||||
setUser(normalizeUser(result.user))
|
||||
|
||||
if (inIframe) {
|
||||
// 弹窗模式:通知父窗口关闭弹窗并刷新绑定状态
|
||||
postWechatQrResult("bind", true)
|
||||
return
|
||||
}
|
||||
|
||||
// 用 replace 回设置页,query 携带成功标记由设置页提示
|
||||
navigate("/app/profile?wechat_bind=success", { replace: true })
|
||||
} catch {
|
||||
navigate("/app/profile?wechat_bind=failed", { replace: true })
|
||||
} catch (err) {
|
||||
// 绑定失败直接在本页展示/上报真实原因(如微信已被其他账号绑定),不静默跳走
|
||||
fail(`微信绑定失败:${getErrorMessage(err, "请回到设置页重试")}`)
|
||||
}
|
||||
}
|
||||
|
||||
handleBind()
|
||||
}, [searchParams, navigate, setUser])
|
||||
}, [searchParams, navigate, setUser, inIframe])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
||||
@@ -2,13 +2,20 @@
|
||||
* 微信登录回调页
|
||||
* 扫码授权后由微信重定向回来:用 code 换登录态,
|
||||
* 新用户/资料未完善 → 跳昵称引导页;老用户 → 回来源页/首页
|
||||
*
|
||||
* 两种运行环境:
|
||||
* - 整页跳转授权(旧流程/兜底):本页整页加载,按上述逻辑导航
|
||||
* - 弹窗内嵌二维码(WxLogin self_redirect):本页在同源 iframe 内加载,
|
||||
* 成功/失败均通过 postMessage 通知父窗口弹窗,不做页面导航
|
||||
*/
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { useSearchParams, useNavigate } from "react-router-dom"
|
||||
import { Spin } from "antd"
|
||||
import { wechatCallback, getCurrentUser, normalizeUser, type User } from "@/api/auth"
|
||||
import { getErrorMessage } from "@/api/errors"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { scheduleProactiveRefresh } from "@/api/auth/tokenRefresh"
|
||||
import { isInIframe, postWechatQrResult } from "@/components/auth/WechatQrModal/messages"
|
||||
|
||||
const WechatCallback: React.FC = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
@@ -16,26 +23,41 @@ const WechatCallback: React.FC = () => {
|
||||
const setAuth = useAuthStore((state) => state.setAuth)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const inIframe = isInIframe()
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams.get("code")
|
||||
const state = searchParams.get("state")
|
||||
|
||||
if (!code || !state) {
|
||||
setError("无效的回调参数")
|
||||
const fail = (message: string) => {
|
||||
if (inIframe) {
|
||||
// 弹窗模式:把真实原因上报父窗口在 Modal 内展示,本页保持"处理中"即可
|
||||
postWechatQrResult("login", false, { detail: message })
|
||||
return
|
||||
}
|
||||
setError(message)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// 微信重定向出错时(如用户拒绝授权 error=access_denied)直接展示/上报原因
|
||||
const wxErrorCode = searchParams.get("error")
|
||||
const wxErrDesc = searchParams.get("error_description")
|
||||
if (wxErrorCode || wxErrDesc) {
|
||||
const reason = [wxErrorCode, wxErrDesc].filter(Boolean).join(":")
|
||||
fail(`微信授权失败:${reason}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
fail("无效的回调参数,请重新扫码登录")
|
||||
return
|
||||
}
|
||||
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
// 校验 state,防止 CSRF
|
||||
const savedState = localStorage.getItem("wechat_state")
|
||||
if (!savedState || savedState !== state) {
|
||||
setError("安全校验失败,请重新登录")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
// state 的 CSRF 校验由后端 state store 一次性消费兜底(前端不再比对
|
||||
// localStorage——微信内打开、跨浏览器等场景本地没有 state,会误杀正常回调);
|
||||
// 清理登录前写入的 state,避免残留
|
||||
localStorage.removeItem("wechat_state")
|
||||
|
||||
const result = await wechatCallback(code, state)
|
||||
@@ -54,6 +76,13 @@ const WechatCallback: React.FC = () => {
|
||||
|
||||
// 新用户 或 资料未完善(如上次中断没填昵称)→ 强制昵称引导
|
||||
const needOnboarding = result.is_new_user || user.profile_completed === false
|
||||
|
||||
if (inIframe) {
|
||||
// 弹窗模式:token 已写入同源 localStorage,通知父窗口同步登录态并跳转
|
||||
postWechatQrResult("login", true, { needOnboarding })
|
||||
return
|
||||
}
|
||||
|
||||
if (needOnboarding) {
|
||||
navigate("/welcome/wechat", { replace: true })
|
||||
return
|
||||
@@ -63,14 +92,14 @@ const WechatCallback: React.FC = () => {
|
||||
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
} catch {
|
||||
setError("微信登录失败,请重试")
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
// 透传后端真实错误(如 state 过期、code 已消费、接口异常),禁止吞成通用提示
|
||||
fail(`微信登录失败:${getErrorMessage(err, "请重试或更换登录方式")}`)
|
||||
}
|
||||
}
|
||||
|
||||
handleCallback()
|
||||
}, [searchParams, navigate, setAuth])
|
||||
}, [searchParams, navigate, setAuth, inIframe])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
* 微信新用户昵称引导页
|
||||
* 新微信用户首次登录后强制填写昵称,完成后才进入主界面
|
||||
*/
|
||||
import React from "react"
|
||||
import React, { useRef } from "react"
|
||||
import { Form, Input, message } from "antd"
|
||||
import { Navigate, useNavigate } from "react-router-dom"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { updateProfile } from "@/api/auth"
|
||||
import { getErrorMessage, isErrorMsgShown } from "@/api/errors"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import Button from "@/components/ui/Button"
|
||||
import "./Login.css"
|
||||
@@ -22,6 +23,10 @@ const WechatOnboarding: React.FC = () => {
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
|
||||
const [form] = Form.useForm<OnboardingFormValues>()
|
||||
// 同步防连点守卫:antd loading 要等 React 重渲染后才禁用按钮,
|
||||
// 连点两次时第一次的 mutation 刚触发、重渲染未发生,第二次 click 仍会进来
|
||||
// (截图里 PATCH /me 405 出现两次就是连点导致的重复提交)
|
||||
const submittingRef = useRef(false)
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (displayName: string) => updateProfile({ display_name: displayName }),
|
||||
@@ -37,6 +42,8 @@ const WechatOnboarding: React.FC = () => {
|
||||
}
|
||||
|
||||
const onFinish = async (values: OnboardingFormValues) => {
|
||||
if (submittingRef.current) return
|
||||
submittingRef.current = true
|
||||
try {
|
||||
const updated = await saveMutation.mutateAsync(values.display_name.trim())
|
||||
// 后端返回的 profile_completed 以最新资料为准,前端同步标记完善
|
||||
@@ -45,9 +52,14 @@ const WechatOnboarding: React.FC = () => {
|
||||
const redirect = localStorage.getItem("login_redirect") || "/app/dashboard"
|
||||
localStorage.removeItem("login_redirect")
|
||||
navigate(redirect, { replace: true })
|
||||
} catch {
|
||||
message.error("保存失败,请重试")
|
||||
} catch (err) {
|
||||
// 透传后端真实原因(如接口异常/校验失败);拦截器已弹过的不重复弹
|
||||
if (!isErrorMsgShown(err)) {
|
||||
message.error(`昵称保存失败:${getErrorMessage(err, "请稍后重试")}`)
|
||||
}
|
||||
submittingRef.current = false
|
||||
}
|
||||
// 成功时页面跳走,不复位
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -67,7 +79,8 @@ const WechatOnboarding: React.FC = () => {
|
||||
onFinish={onFinish}
|
||||
autoComplete="off"
|
||||
layout="vertical"
|
||||
initialValues={{ display_name: user?.display_name || "" }}
|
||||
// 不预填:新微信用户必须自己输入昵称(user.display_name 可能是微信昵称/系统占位)
|
||||
initialValues={{ display_name: "" }}
|
||||
>
|
||||
<Form.Item
|
||||
name="display_name"
|
||||
@@ -88,6 +101,7 @@ const WechatOnboarding: React.FC = () => {
|
||||
buttonSize="lg"
|
||||
htmlType="submit"
|
||||
loading={saveMutation.isPending}
|
||||
disabled={saveMutation.isPending}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{saveMutation.isPending ? "保存中..." : "进入小虾智剪"}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useStepNavigation } from "./hooks/useStepNavigation"
|
||||
import { useGenerateVideo } from "./hooks/useGenerateVideo"
|
||||
|
||||
import { usePreviewAssets } from "./hooks/usePreviewAssets"
|
||||
import { useBatchVariantPlans } from "./hooks/useBatchVariantPlans"
|
||||
import { useTitleStyleUpdaters } from "./hooks/useStep4Title/useTitleStyleUpdaters"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import { previewTts } from "@/api/tts"
|
||||
@@ -221,6 +222,21 @@ const GeneratePage: React.FC = () => {
|
||||
/* ── 预览就绪:纯前端 Canvas 预览,素材详情加载完即可秒开(单视频/批量一致) ── */
|
||||
const previewReady = previewAssetsReady && !!currentTemplate
|
||||
|
||||
/* ── 批量变体真实片段(#1744):后端独立选片,预览即成片;失败静默降级本地模拟 ──
|
||||
仅批量(N>1)且在第 4 步预览时申请,避免选素材阶段频繁请求;
|
||||
变体 0 沿用草稿 plan(与单视频一致),变体 1..N-1 后端 reselect 独立选片 */
|
||||
const {
|
||||
clipsByVariant: variantClips,
|
||||
planIdsByVariant: variantPlanIds,
|
||||
loading: variantClipsLoading,
|
||||
} = useBatchVariantPlans({
|
||||
enabled: isBatch && currentStep === 4 && previewAssetsReady,
|
||||
count: previewCount,
|
||||
templateId: selectedTemplate || "",
|
||||
assetIds: previewAssetIds,
|
||||
sourcePlanId: storedSourceEditPlanId || sourceEditPlanId || "",
|
||||
})
|
||||
|
||||
/* ── 勾选变体 ── */
|
||||
const toggleVariantSelect = useCallback(
|
||||
(index: number) => {
|
||||
@@ -262,6 +278,8 @@ const GeneratePage: React.FC = () => {
|
||||
autoSubtitles,
|
||||
bgm,
|
||||
sourceEditPlanId: storedSourceEditPlanId || sourceEditPlanId,
|
||||
// #1744:批量预览阶段后端为每个变体生成的独立 plan id,正式生成回传 → 预览即成片
|
||||
variantPlanIds,
|
||||
previewTaskId,
|
||||
bgmConfig,
|
||||
previewCount,
|
||||
@@ -430,6 +448,8 @@ const GeneratePage: React.FC = () => {
|
||||
titles={previewTitles}
|
||||
titleSettings={titleSettings}
|
||||
voiceAudioUrl={previewVoiceAudioUrl || undefined}
|
||||
variantClips={variantClips}
|
||||
clipsLoading={variantClipsLoading}
|
||||
selectedIds={selectedVariantIds}
|
||||
onToggleSelect={toggleVariantSelect}
|
||||
selectable={!generating}
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
/**
|
||||
* 批量前端 Canvas 实时预览网格(Issue #1677 修正方案)
|
||||
* 批量前端 Canvas 实时预览网格(Issue #1677 修正方案,#1744 对齐后端真实选片)
|
||||
*
|
||||
* N 个 FrontendPreviewPlayer 网格排列:
|
||||
* - 纯前端 Canvas + video 元素实时播放素材片段,不调任何后端渲染接口
|
||||
* - variantSeed 让每个变体素材排布/起始点不同,画面有可见差异
|
||||
* - #1744:优先使用后端变体独立选片返回的真实片段(variantClips)——与正式
|
||||
* 批量生成同源自 reselect_plan_for_variant,预览素材排布/起点即成片;
|
||||
* 后端片段未就绪(端点未上线/降级)时回退 variantSeed 本地模拟,保证可用
|
||||
* - 各自叠加独立标题浮层(variantTitle),标题样式全局共用
|
||||
* - 勾选框决定提交时生成哪些变体
|
||||
* - 每个变体都挂载同一条配音 URL(浏览器缓存不重复下载);播放互斥:
|
||||
* 点击某卡片播放时其他卡片自动暂停,同一时刻只有一路声音(#1741)
|
||||
*/
|
||||
import React from "react"
|
||||
import React, { useState } from "react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import type { TitleSettings } from "../types"
|
||||
import FrontendPreviewPlayer from "./FrontendPreviewPlayer"
|
||||
|
||||
@@ -20,8 +26,15 @@ interface CanvasPreviewGridProps {
|
||||
videoRatio: string
|
||||
titles: string[]
|
||||
titleSettings: TitleSettings
|
||||
/** 共用配音预览音频(仅第 1 个变体播放,避免多路音频重叠) */
|
||||
/** 共用配音预览音频 URL(#1741:每个变体都挂载,播放互斥保证同一时刻只有一路发声) */
|
||||
voiceAudioUrl?: string
|
||||
/**
|
||||
* 各变体的后端真实片段(#1744):长度=count,空数组=该变体未就绪/降级本地模拟。
|
||||
* 与正式批量生成同源选片,预览所见即成片。
|
||||
*/
|
||||
variantClips?: EditPlanClip[][]
|
||||
/** 是否正在向后端申请变体计划(显示轻量加载提示,不阻塞本地模拟预览) */
|
||||
clipsLoading?: boolean
|
||||
/** 勾选的变体序号 */
|
||||
selectedIds: number[]
|
||||
onToggleSelect: (index: number) => void
|
||||
@@ -37,10 +50,16 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
|
||||
titles,
|
||||
titleSettings,
|
||||
voiceAudioUrl,
|
||||
variantClips,
|
||||
clipsLoading = false,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
selectable = true,
|
||||
}) => {
|
||||
// ── 播放互斥(#1741):同一时刻只有一个卡片持有播放权,点击其他卡片自动暂停当前卡片 ──
|
||||
// token 用 variantSeed(i+1),与 FrontendPreviewPlayer 内部 variantSeed 一致
|
||||
const [activePlayToken, setActivePlayToken] = useState<number | null>(null)
|
||||
|
||||
// count 上限已在源头 PreviewCountModal 的数量选择(1~MAX_PREVIEW_COUNT=10)clamp,
|
||||
// 这里完整渲染所有变体,保证每个变体都有勾选/预览入口,UI 与数据不脱节
|
||||
return (
|
||||
@@ -64,14 +83,27 @@ const CanvasPreviewGrid: React.FC<CanvasPreviewGridProps> = ({
|
||||
<span>视频 {i + 1}</span>
|
||||
</label>
|
||||
</div>
|
||||
{clipsLoading && (!variantClips || variantClips[i]?.length === 0) && (
|
||||
<div className="xx-variant-clips-loading" aria-label={`变体${i + 1}片段加载中`}>
|
||||
<LoadingOutlined />
|
||||
<span>独立选片中…</span>
|
||||
</div>
|
||||
)}
|
||||
<FrontendPreviewPlayer
|
||||
assets={assets}
|
||||
template={template}
|
||||
videoRatio={videoRatio}
|
||||
ready={assets.length > 0}
|
||||
variantSeed={i + 1}
|
||||
// #1744:有后端真实片段时播放器优先使用(buildPlaybackSegments 内 serverClips 优先),
|
||||
// 与正式成片同源;为空则自动回退 variantSeed 本地模拟
|
||||
serverClips={
|
||||
variantClips && variantClips[i]?.length > 0 ? variantClips[i] : undefined
|
||||
}
|
||||
variantTitle={titles[i] || ""}
|
||||
voiceAudioUrl={i === 0 ? voiceAudioUrl : undefined}
|
||||
voiceAudioUrl={voiceAudioUrl}
|
||||
activePlayToken={activePlayToken}
|
||||
onPlayTokenChange={setActivePlayToken}
|
||||
compact
|
||||
titleSettings={{
|
||||
title: titles[i] || "",
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
PauseCircleOutlined,
|
||||
SoundOutlined,
|
||||
LoadingOutlined,
|
||||
AudioOutlined,
|
||||
AudioMutedOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
@@ -51,6 +53,13 @@ interface FrontendPreviewPlayerProps {
|
||||
variantTitle?: string
|
||||
/** 紧凑模式(批量网格中使用,缩小内边距/标题尺寸) */
|
||||
compact?: boolean
|
||||
/**
|
||||
* 批量网格播放互斥(#1741):当前持有播放权的实例 token(variantSeed)。
|
||||
* 持有权变化且不等于自身时,本实例自动暂停(视频+配音)。单视频模式不传。
|
||||
*/
|
||||
activePlayToken?: number | null
|
||||
/** 播放权变化回调:本实例请求播放时传自身 variantSeed,暂停时传 null */
|
||||
onPlayTokenChange?: (token: number | null) => void
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
@@ -157,6 +166,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
variantSeed = 0,
|
||||
variantTitle,
|
||||
compact = false,
|
||||
activePlayToken = null,
|
||||
onPlayTokenChange,
|
||||
}) => {
|
||||
const segments = useMemo(
|
||||
() => buildPlaybackSegments(assets, template, serverClips, variantSeed),
|
||||
@@ -339,6 +350,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
canPlay: videoCanPlay,
|
||||
togglePlayPause: videoTogglePlayPause,
|
||||
seekTo: videoSeekTo,
|
||||
pause: videoPause,
|
||||
videoRefs,
|
||||
} = useSegmentScheduler(segments)
|
||||
|
||||
@@ -353,6 +365,10 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
// ── 配音音频同步 ──
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const prevIsPlayingRef = useRef(false)
|
||||
// 本卡片静音开关(#1741):默认有声,用户可点喇叭单独静音某张卡片
|
||||
const [muted, setMuted] = useState(false)
|
||||
// 有配音时 video 素材保持静音(避免原声与配音混音);无配音时取消静音,素材原声兜底
|
||||
const hasVoice = !!voiceAudioUrl
|
||||
|
||||
useEffect(() => {
|
||||
if (!voiceAudioUrl) {
|
||||
@@ -370,7 +386,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
if (audioRef.current.src !== voiceAudioUrl) {
|
||||
audioRef.current.src = voiceAudioUrl
|
||||
}
|
||||
}, [voiceAudioUrl])
|
||||
audioRef.current.muted = muted
|
||||
}, [voiceAudioUrl, muted])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
@@ -409,17 +426,42 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
[effectiveUseWebCodecs, canvasControls, videoSeekTo],
|
||||
)
|
||||
|
||||
// ── 批量网格播放互斥(#1741):播放权属于其他实例时,本实例自动暂停(视频+配音) ──
|
||||
useEffect(() => {
|
||||
if (activePlayToken == null || activePlayToken === variantSeed) return
|
||||
if (effectiveUseWebCodecs) {
|
||||
if (canvasState.isPlaying) canvasControls.pause()
|
||||
} else if (isPlaying) {
|
||||
videoPause()
|
||||
}
|
||||
// isPlaying/canvasState.isPlaying 不放依赖:只在 token 变化时执行一次暂停,
|
||||
// token 等于自身时本实例的播放在 handleTogglePlay 里处理
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activePlayToken, variantSeed, effectiveUseWebCodecs])
|
||||
|
||||
const handleTogglePlay = useCallback(() => {
|
||||
if (effectiveUseWebCodecs) {
|
||||
if (canvasState.isPlaying) {
|
||||
canvasControls.pause()
|
||||
onPlayTokenChange?.(null)
|
||||
} else {
|
||||
onPlayTokenChange?.(variantSeed)
|
||||
canvasControls.play()
|
||||
}
|
||||
} else {
|
||||
// video fallback:先上报播放权(暂停其他卡片),再切换本卡片播放/暂停
|
||||
onPlayTokenChange?.(isPlaying ? null : variantSeed)
|
||||
videoTogglePlayPause()
|
||||
}
|
||||
}, [effectiveUseWebCodecs, canvasState.isPlaying, canvasControls, videoTogglePlayPause])
|
||||
}, [
|
||||
effectiveUseWebCodecs,
|
||||
canvasState.isPlaying,
|
||||
canvasControls,
|
||||
videoTogglePlayPause,
|
||||
isPlaying,
|
||||
variantSeed,
|
||||
onPlayTokenChange,
|
||||
])
|
||||
|
||||
// ── 进度条拖拽 ──
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
@@ -608,7 +650,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
segments.map((seg, i) => (
|
||||
<video
|
||||
key={seg.assetId}
|
||||
muted
|
||||
muted={hasVoice || muted}
|
||||
ref={(el) => {
|
||||
videoRefs.current[i] = el
|
||||
}}
|
||||
@@ -743,6 +785,45 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 静音/有声切换(#1741):左上角,默认有声;批量与单视频均可单独静音 */}
|
||||
{segments.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={muted ? "取消静音" : "静音"}
|
||||
title={muted ? "取消静音" : "静音"}
|
||||
onClick={() => setMuted((m) => !m)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
width: compact ? 26 : 30,
|
||||
height: compact ? 26 : 30,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: "50%",
|
||||
color: muted ? "rgba(255,255,255,0.45)" : "rgba(255,255,255,0.92)",
|
||||
fontSize: compact ? 13 : 15,
|
||||
cursor: "pointer",
|
||||
zIndex: 10,
|
||||
padding: 0,
|
||||
transition: "background 0.15s, color 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.65)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.45)"
|
||||
}}
|
||||
>
|
||||
{muted ? <AudioMutedOutlined /> : <AudioOutlined />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 片段指示器 — 右上角胶囊 */}
|
||||
<div
|
||||
style={{
|
||||
@@ -753,9 +834,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
backdropFilter: "blur(8px)",
|
||||
WebkitBackdropFilter: "blur(8px)",
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
fontSize: 10,
|
||||
fontSize: compact ? 9 : 10,
|
||||
fontWeight: 500,
|
||||
padding: "2px 8px",
|
||||
padding: compact ? "1px 6px" : "2px 8px",
|
||||
borderRadius: 999,
|
||||
zIndex: 10,
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
@@ -774,8 +855,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
right: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "12px 16px 16px",
|
||||
gap: compact ? 6 : 10,
|
||||
padding: compact ? "8px 10px 10px" : "12px 16px 16px",
|
||||
background: "linear-gradient(transparent, rgba(0,0,0,0.7))",
|
||||
backdropFilter: "blur(4px)",
|
||||
WebkitBackdropFilter: "blur(4px)",
|
||||
@@ -788,10 +869,10 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
background: "rgba(255,255,255,0.15)",
|
||||
border: "none",
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontSize: compact ? 14 : 16,
|
||||
cursor: "pointer",
|
||||
width: 32,
|
||||
height: 32,
|
||||
width: compact ? 26 : 32,
|
||||
height: compact ? 26 : 32,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -811,9 +892,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontSize: compact ? 10 : 11,
|
||||
color: "rgba(255,255,255,0.85)",
|
||||
minWidth: 72,
|
||||
minWidth: compact ? 58 : 72,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
letterSpacing: 0.2,
|
||||
}}
|
||||
|
||||
@@ -9,12 +9,13 @@
|
||||
* - 标题样式(字体/颜色/位置/大小/粗斜描边/预设):全局统一
|
||||
*/
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { AutoComplete, Input, message } from "antd"
|
||||
import { Input, message } from "antd"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
|
||||
import { useStep4Title } from "../hooks/useStep4Title"
|
||||
import AiTitleGenerator from "./title/AiTitleGenerator"
|
||||
import TitleLibraryAutoComplete from "./title/TitleLibraryAutoComplete"
|
||||
import TitleStylePanel from "./title/TitleStylePanel"
|
||||
import { AI_TITLE_TEMPLATES } from "../constants"
|
||||
|
||||
@@ -201,21 +202,14 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
</div>
|
||||
<div className="xx-form-field">
|
||||
<label>标题</label>
|
||||
<AutoComplete
|
||||
placeholder="输入标题文字…"
|
||||
allowClear
|
||||
maxLength={50}
|
||||
style={{ width: "100%" }}
|
||||
value={(previewTitles?.[0] ?? t.titleSettings.title) || undefined}
|
||||
<TitleLibraryAutoComplete
|
||||
placeholder="输入或从标题库选择"
|
||||
value={previewTitles?.[0] ?? t.titleSettings.title}
|
||||
onChange={(val) => {
|
||||
t.updateTitle(val || "")
|
||||
onPreviewTitlesChange?.([val || ""])
|
||||
}}
|
||||
options={titleOptions}
|
||||
filterOption={(inputValue, option) => {
|
||||
const title = (option?.label || option?.value || "") as string
|
||||
return title.toLowerCase().includes((inputValue || "").toLowerCase())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -269,17 +263,11 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
{Array.from({ length: previewCount }, (_, i) => (
|
||||
<div className="xx-form-field" key={i}>
|
||||
<label>视频 {i + 1} 标题</label>
|
||||
<AutoComplete
|
||||
placeholder={`视频 ${i + 1} 的标题…`}
|
||||
maxLength={50}
|
||||
style={{ width: "100%" }}
|
||||
value={previewTitles?.[i] || undefined}
|
||||
onChange={(val) => updateVariantTitle(i, val || "")}
|
||||
<TitleLibraryAutoComplete
|
||||
placeholder={`输入或选择视频 ${i + 1} 的标题`}
|
||||
value={previewTitles?.[i] || ""}
|
||||
onChange={(val) => updateVariantTitle(i, val)}
|
||||
options={titleOptions}
|
||||
filterOption={(inputValue, option) => {
|
||||
const title = (option?.label || option?.value || "") as string
|
||||
return title.toLowerCase().includes((inputValue || "").toLowerCase())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 标题库 AutoComplete(Issue #1737)
|
||||
*
|
||||
* 原生 antd AutoComplete(combobox 模式)的两个行为不符合产品预期:
|
||||
* 1. combobox 默认 showAction=[],输入框聚焦时下拉不展开——用户必须先打字才能看到标题库,
|
||||
* 且组件无下拉箭头,视觉上是"纯输入框",不知道标题库里已有标题可选。
|
||||
* 2. 空态聚焦不展示任何标题库内容。
|
||||
*
|
||||
* 本组件封装修复:
|
||||
* - 受控 open:聚焦(且标题库非空)即展开,展示全部标题;失焦/选中/Esc 关闭
|
||||
* (rc-select 失焦会主动 onToggleOpen(false),onOpenChange 同步状态即可,不会死循环)
|
||||
* - suffixIcon 加下拉三角,视觉提示"可选择";有值时 allowClear 的清除按钮照常出现
|
||||
* - 输入文字时由 filterOption 过滤(空串展示全部)
|
||||
* - 保留 combobox 自由输入能力:用户可输入标题库之外的自定义标题
|
||||
*/
|
||||
import React, { useState } from "react"
|
||||
import { AutoComplete } from "antd"
|
||||
import { DownOutlined } from "@ant-design/icons"
|
||||
import type { AutoCompleteProps } from "antd"
|
||||
|
||||
export interface TitleOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface TitleLibraryAutoCompleteProps {
|
||||
value: string
|
||||
onChange: (val: string) => void
|
||||
options: TitleOption[]
|
||||
placeholder?: string
|
||||
allowClear?: boolean
|
||||
maxLength?: number
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const TitleLibraryAutoComplete: React.FC<TitleLibraryAutoCompleteProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = "输入或从标题库选择",
|
||||
allowClear = true,
|
||||
maxLength = 50,
|
||||
style,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const hasTitles = options.length > 0
|
||||
|
||||
const filterOption: AutoCompleteProps["filterOption"] = (inputValue, option) => {
|
||||
const title = (option?.label || option?.value || "") as string
|
||||
return title.toLowerCase().includes((inputValue || "").toLowerCase())
|
||||
}
|
||||
|
||||
return (
|
||||
<AutoComplete
|
||||
value={value || undefined}
|
||||
onChange={(val) => onChange(val || "")}
|
||||
options={options}
|
||||
filterOption={filterOption}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onFocus={() => {
|
||||
// 标题库为空时不展开(避免弹出"暂无数据"空壳)
|
||||
if (hasTitles) setOpen(true)
|
||||
}}
|
||||
onSelect={() => setOpen(false)}
|
||||
suffixIcon={<DownOutlined style={{ color: "var(--text-secondary, #bbb)", fontSize: 12 }} />}
|
||||
placeholder={placeholder}
|
||||
allowClear={allowClear}
|
||||
maxLength={maxLength}
|
||||
style={{ width: "100%", ...style }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitleLibraryAutoComplete
|
||||
@@ -3282,13 +3282,24 @@
|
||||
/* ============================================================
|
||||
批量前端 Canvas 预览网格(Issue #1677 修正:纯前端实时预览)
|
||||
============================================================ */
|
||||
/* #1741:卡片整体缩小至约 3/5——宽屏排 3 列(卡片限宽 220px 居中),
|
||||
中屏自动回退 2 列,窄屏 1 列(见下方媒体查询);卡片保持 9:16 比例不变形 */
|
||||
.xx-canvas-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 220px));
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* 窄屏单列时卡片限宽居中,避免 1fr 拉伸导致卡片过高 */
|
||||
@media (max-width: 960px) {
|
||||
.xx-canvas-grid {
|
||||
grid-template-columns: minmax(0, 320px);
|
||||
}
|
||||
}
|
||||
|
||||
.xx-canvas-grid-card {
|
||||
position: relative;
|
||||
border: 2px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
@@ -3330,6 +3341,27 @@
|
||||
accent-color: var(--primary-color, #1677ff);
|
||||
}
|
||||
|
||||
/* #1744:批量变体独立选片加载提示(浮在卡片右上角,不遮挡播放控件) */
|
||||
.xx-variant-clips-loading {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
right: 8px;
|
||||
z-index: 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border-radius: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xx-variant-clips-loading .anticon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
批量标题:AI 一键生成行(Issue #1677)
|
||||
============================================================ */
|
||||
@@ -3435,9 +3467,8 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ── 响应式:窄屏批量网格回退单列 ── */
|
||||
/* ── 响应式:窄屏批量网格回退单列(.xx-canvas-grid 的窄屏限宽见网格定义处 #1741) ── */
|
||||
@media (max-width: 960px) {
|
||||
.xx-canvas-grid,
|
||||
.xx-batch-gen-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ export interface UseGenerateVideoProps {
|
||||
variantCoverUrls?: string[]
|
||||
/** 勾选要生成的变体索引(批量模式) */
|
||||
selectedVariantIndexes?: number[]
|
||||
/**
|
||||
* 批量变体剪辑计划 ID(#1744,按变体全量索引长度=previewCount):
|
||||
* 预览阶段后端独立选片产出,正式生成按勾选顺序回传,实现预览即成片;
|
||||
* 为空(降级本地模拟/后端未上线)时不传,后端自行独立选片。
|
||||
*/
|
||||
variantPlanIds?: string[]
|
||||
}
|
||||
|
||||
/** 生成阶段 */
|
||||
|
||||
@@ -13,21 +13,26 @@ interface UseSmartMatchOptions {
|
||||
}
|
||||
|
||||
/** 默认 limit(拿不到目标时长时的兜底上限) */
|
||||
const DEFAULT_LIMIT = 10
|
||||
/** 每个素材切片按 15 秒估算所需素材数 */
|
||||
const SECONDS_PER_ASSET = 15
|
||||
const DEFAULT_LIMIT = 30
|
||||
/**
|
||||
* 候选池放大倍数(#1744):批量 N 个变体独立选片、跨变体 20% 区间避让需要
|
||||
* 足够大的素材池才能保证 N 条成片素材排布互不相同(历史已用区间写回素材 metadata)。
|
||||
* 旧规则「总时长/15 秒」只够 1 条成片的选片量,池太小会导致变体间大量复用同区间。
|
||||
*/
|
||||
const ASSET_POOL_MULTIPLIER = 3
|
||||
|
||||
/**
|
||||
* 根据模板 segments 计算所需素材数量上限。
|
||||
* 取每个 segment 的 duration_min 之和作为目标视频总时长,
|
||||
* 再按 15 秒/素材估算需要多少个素材,且保证不少于片段数(每个片段至少 1 个素材);
|
||||
* 根据模板 segments 计算智能匹配候选素材数量上限(#1744 调整)。
|
||||
* 候选池至少为「片段数 × 3」:
|
||||
* - 每个片段至少有 3 个候选素材可供变体间洗牌/避让(独立选片 + 区间不重叠);
|
||||
* - 同时不低于「总时长/15 秒」的时长覆盖率估算,两者取大;
|
||||
* 结果钳制到 [1, 200] 区间(后端 limit 上限 200)。
|
||||
*/
|
||||
function computeLimitFromSegments(segments?: TemplateSegment[]): number {
|
||||
export function computeLimitFromSegments(segments?: TemplateSegment[]): number {
|
||||
if (!segments || segments.length === 0) return DEFAULT_LIMIT
|
||||
const totalSeconds = segments.reduce((sum, seg) => sum + (seg.duration_min || 0), 0)
|
||||
if (totalSeconds <= 0) return DEFAULT_LIMIT
|
||||
const limit = Math.max(segments.length, Math.ceil(totalSeconds / SECONDS_PER_ASSET))
|
||||
const byDuration = totalSeconds > 0 ? Math.ceil(totalSeconds / 15) : 0
|
||||
const limit = Math.max(segments.length * ASSET_POOL_MULTIPLIER, byDuration, segments.length)
|
||||
return Math.max(1, Math.min(limit, 200))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 批量变体真实片段 Hook(#1744)
|
||||
*
|
||||
* 批量预览(N>1)进入第 4 步时,向后端轻量接口 POST /generation/variant-plans
|
||||
* 申请 N 个变体的独立剪辑计划片段:
|
||||
* - 接口只做选片/建 plan(秒级),不渲染视频,无渲染成本;
|
||||
* - 选片逻辑与正式批量生成(POST /generation/tasks?count=N)完全同源
|
||||
* (reselect_plan_for_variant:素材洗牌 + main 片段洗牌 + 起点随机 +
|
||||
* 跨变体 20% 区间避让 + 使用区间写回素材 metadata),
|
||||
* 因此前端按这些 clips 播放的预览与最终成片一致;
|
||||
* - 正式生成时把 plan_ids 回传,后端直接关联预览 plan 渲染,不再重新选片。
|
||||
*
|
||||
* 降级策略(不阻塞用户):
|
||||
* - 端点 404(后端未上线)/ 网络错误 / 超时:静默降级为本地 variantSeed 模拟;
|
||||
* - 400(素材库不足无法独立选片):展示后端提示一次,降级本地模拟
|
||||
* (正式生成时后端仍会拦截并给出同样提示,不会静默出同源成片);
|
||||
* - 单个变体 clips 为空:该变体降级本地模拟。
|
||||
*
|
||||
* N=1 不调用本 hook(单视频零回归)。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { message } from "antd"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
import { createBatchVariantPlans, type VariantPlan } from "@/api/generation/variantPlans"
|
||||
|
||||
export interface BatchVariantClipsState {
|
||||
/** 各变体的服务端真实片段(按 variant_index 排序);未就绪/降级的变体为空数组 */
|
||||
clipsByVariant: EditPlanClip[][]
|
||||
/** 各变体的 plan_id(正式生成回传,保证预览即成片);降级/未就绪为空串 */
|
||||
planIdsByVariant: string[]
|
||||
/** 是否正在向后端申请变体计划 */
|
||||
loading: boolean
|
||||
/** 后端真实片段是否可用(至少变体 0 有片段);false 时调用方应走本地模拟 */
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
interface UseBatchVariantPlansOptions {
|
||||
/** 是否启用:仅批量(count>1)且素材已选时为 true */
|
||||
enabled: boolean
|
||||
count: number
|
||||
templateId: string
|
||||
assetIds: string[]
|
||||
/** 源剪辑计划 ID(草稿/预览关联),无则空串由后端兜底最新 plan */
|
||||
sourcePlanId?: string
|
||||
}
|
||||
|
||||
export function useBatchVariantPlans({
|
||||
enabled,
|
||||
count,
|
||||
templateId,
|
||||
assetIds,
|
||||
sourcePlanId = "",
|
||||
}: UseBatchVariantPlansOptions): BatchVariantClipsState {
|
||||
const [clipsByVariant, setClipsByVariant] = useState<EditPlanClip[][]>([])
|
||||
const [planIdsByVariant, setPlanIdsByVariant] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const requestSeqRef = useRef(0)
|
||||
const warnedRef = useRef(false)
|
||||
// 记录上次成功申请的入参指纹,素材/数量未变时不重复请求
|
||||
const lastKeyRef = useRef("")
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const seq = ++requestSeqRef.current
|
||||
setLoading(true)
|
||||
try {
|
||||
const resp = await createBatchVariantPlans({
|
||||
template_id: templateId,
|
||||
asset_ids: assetIds,
|
||||
count,
|
||||
...(sourcePlanId ? { source_edit_plan_id: sourcePlanId } : {}),
|
||||
})
|
||||
if (seq !== requestSeqRef.current) return
|
||||
|
||||
const items: VariantPlan[] = Array.isArray(resp.items) ? resp.items : []
|
||||
const clips: EditPlanClip[][] = Array.from({ length: count }, () => [])
|
||||
const planIds: string[] = Array.from({ length: count }, () => "")
|
||||
for (const item of items) {
|
||||
const idx = item.variant_index
|
||||
if (idx < 0 || idx >= count) continue
|
||||
planIds[idx] = item.plan_id || ""
|
||||
clips[idx] = (item.clips || [])
|
||||
.filter((c) => c && c.asset_id && c.status === "ready")
|
||||
.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
setClipsByVariant(clips)
|
||||
setPlanIdsByVariant(planIds)
|
||||
} catch (err) {
|
||||
if (seq !== requestSeqRef.current) return
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
if (status === 404) {
|
||||
// 后端端点未上线:静默降级(本地 variantSeed 模拟),不打扰用户
|
||||
setClipsByVariant([])
|
||||
setPlanIdsByVariant([])
|
||||
} else if (status === 400) {
|
||||
// 素材不足等业务错误:后端 detail 已由全局拦截器 toast,这里只标记降级,不重复提示
|
||||
setClipsByVariant([])
|
||||
setPlanIdsByVariant([])
|
||||
} else if (!warnedRef.current) {
|
||||
// 网络/超时/5xx:提示一次后静默降级
|
||||
warnedRef.current = true
|
||||
console.warn("[useBatchVariantPlans] 申请变体计划失败,降级本地模拟预览:", err)
|
||||
message.info("预览素材排布加载失败,正式生成时每个视频仍会独立随机选片")
|
||||
setClipsByVariant([])
|
||||
setPlanIdsByVariant([])
|
||||
}
|
||||
} finally {
|
||||
if (seq === requestSeqRef.current) setLoading(false)
|
||||
}
|
||||
}, [templateId, count, sourcePlanId, assetIds])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !templateId || assetIds.length === 0 || count <= 1) {
|
||||
requestSeqRef.current += 1
|
||||
// 函数式更新:已是目标值时返回 prev(Object.is 相等 React 跳过渲染),
|
||||
// 避免父组件传入内联字面量数组导致 effect 每次 render 触发 → 无限 setState 循环
|
||||
setClipsByVariant((prev) => (prev.length === 0 ? prev : []))
|
||||
setPlanIdsByVariant((prev) => (prev.length === 0 ? prev : []))
|
||||
setLoading((prev) => (prev === false ? prev : false))
|
||||
lastKeyRef.current = ""
|
||||
return
|
||||
}
|
||||
const key = `${templateId}|${count}|${sourcePlanId}|${[...assetIds].sort().join(",")}`
|
||||
if (key === lastKeyRef.current) return
|
||||
lastKeyRef.current = key
|
||||
load()
|
||||
}, [enabled, templateId, count, sourcePlanId, assetIds, load])
|
||||
|
||||
const ready = clipsByVariant.some((list) => list.length > 0)
|
||||
|
||||
return {
|
||||
clipsByVariant,
|
||||
planIdsByVariant,
|
||||
loading,
|
||||
ready,
|
||||
}
|
||||
}
|
||||
|
||||
export default useBatchVariantPlans
|
||||
@@ -178,6 +178,14 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
isBatch && props.variantCoverUrls?.length
|
||||
? indexes.map((i) => props.variantCoverUrls![i] || "")
|
||||
: []
|
||||
// #1744 变体 plan 数组:预览阶段后端独立选片产出的 plan id,按勾选顺序回传,
|
||||
// 后端直接关联这些 plan 渲染(不再重新选片)→ 预览所见即成片。
|
||||
// 全部为空(降级本地模拟/后端端点未上线)时不传,后端走自身独立选片。
|
||||
const variantPlansArr =
|
||||
isBatch && props.variantPlanIds?.length
|
||||
? indexes.map((i) => props.variantPlanIds![i] || "")
|
||||
: []
|
||||
const hasVariantPlans = variantPlansArr.some((id) => !!id)
|
||||
|
||||
try {
|
||||
const taskResp = await createGenerationTask({
|
||||
@@ -200,6 +208,7 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
|
||||
...(titlesArr.length ? { titles: titlesArr } : {}),
|
||||
...(voiceArr.length ? { voice_library_ids: voiceArr } : {}),
|
||||
...(coversArr.length ? { cover_urls: coversArr } : {}),
|
||||
...(hasVariantPlans ? { variant_plan_ids: variantPlansArr } : {}),
|
||||
...(props.titleSettings?.title
|
||||
? {
|
||||
title_config: {
|
||||
|
||||
@@ -8,9 +8,10 @@ import { useSearchParams } from "react-router-dom"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { message } from "antd"
|
||||
import { Button, Input, Modal } from "@/components/ui"
|
||||
import { getCurrentUser, updateProfile, getWechatBindUrl, unbindWechat } from "@/api/auth"
|
||||
import { getCurrentUser, updateProfile, unbindWechat } from "@/api/auth"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import WechatQrModal from "@/components/auth/WechatQrModal"
|
||||
import "./ProfileSettings.css"
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
@@ -19,6 +20,7 @@ const Settings: React.FC = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [displayName, setDisplayName] = useState(user?.display_name || "")
|
||||
const [wechatBindOpen, setWechatBindOpen] = useState(false)
|
||||
const bindTipShownRef = useRef(false)
|
||||
|
||||
// 拉取最新用户信息(微信绑定状态以后端为准)
|
||||
@@ -62,14 +64,11 @@ const Settings: React.FC = () => {
|
||||
},
|
||||
})
|
||||
|
||||
const handleBindWechat = async () => {
|
||||
try {
|
||||
const result = await getWechatBindUrl()
|
||||
localStorage.setItem("wechat_bind_state", result.state)
|
||||
window.location.href = result.auth_url
|
||||
} catch {
|
||||
message.error("微信绑定暂不可用,请稍后重试")
|
||||
}
|
||||
// 弹窗扫码绑定成功:关闭弹窗,刷新用户信息并提示
|
||||
const handleBindSuccess = () => {
|
||||
setWechatBindOpen(false)
|
||||
queryClient.invalidateQueries({ queryKey: ["currentUser"] })
|
||||
message.success("微信绑定成功")
|
||||
}
|
||||
|
||||
const unbindMutation = useMutation({
|
||||
@@ -177,13 +176,20 @@ const Settings: React.FC = () => {
|
||||
解绑
|
||||
</Button>
|
||||
) : (
|
||||
<Button buttonType="primary" buttonSize="md" onClick={handleBindWechat}>
|
||||
<Button buttonType="primary" buttonSize="md" onClick={() => setWechatBindOpen(true)}>
|
||||
绑定微信
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WechatQrModal
|
||||
open={wechatBindOpen}
|
||||
scene="bind"
|
||||
onClose={() => setWechatBindOpen(false)}
|
||||
onBindSuccess={handleBindSuccess}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Navigate, type RouteObject } from "react-router-dom"
|
||||
import MainLayout from "@/components/layout/MainLayout"
|
||||
import { ProtectedRoute } from "./ProtectedRoute"
|
||||
import { lazyRoute } from "./lazyRoute"
|
||||
|
||||
/**
|
||||
* 受保护的 /app 子路由
|
||||
@@ -13,202 +14,118 @@ const appChildren: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
path: "dashboard",
|
||||
lazy: () =>
|
||||
import("@/pages/dashboard/Dashboard").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/dashboard/Dashboard")),
|
||||
},
|
||||
{
|
||||
path: "assets",
|
||||
lazy: () =>
|
||||
import("@/pages/assets/AssetLibrary").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/assets/AssetLibrary")),
|
||||
},
|
||||
{
|
||||
path: "titles",
|
||||
lazy: () =>
|
||||
import("@/pages/titles/TitleLibrary").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/titles/TitleLibrary")),
|
||||
},
|
||||
{
|
||||
path: "voices",
|
||||
lazy: () =>
|
||||
import("@/pages/voices/VoiceLibrary").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/voices/VoiceLibrary")),
|
||||
},
|
||||
{
|
||||
path: "templates",
|
||||
lazy: () =>
|
||||
import("@/pages/templates/TemplateLibrary").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/templates/TemplateLibrary")),
|
||||
},
|
||||
{
|
||||
path: "generate",
|
||||
lazy: () =>
|
||||
import("@/pages/generate/GeneratePage").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/generate/GeneratePage")),
|
||||
},
|
||||
{
|
||||
path: "history",
|
||||
lazy: () =>
|
||||
import("@/pages/history/TaskHistory").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/history/TaskHistory")),
|
||||
},
|
||||
{
|
||||
path: "products",
|
||||
lazy: () =>
|
||||
import("@/pages/products/ProductLibrary").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/products/ProductLibrary")),
|
||||
},
|
||||
{
|
||||
path: "products/:id",
|
||||
lazy: () =>
|
||||
import("@/pages/products/ProductDetail").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/products/ProductDetail")),
|
||||
},
|
||||
{
|
||||
path: "tasks",
|
||||
lazy: () =>
|
||||
import("@/pages/tasks/TaskCenter").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/tasks/TaskCenter")),
|
||||
},
|
||||
{
|
||||
path: "editing-planner",
|
||||
lazy: () =>
|
||||
import("@/pages/editing-planner/EditingPlanner").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/editing-planner/EditingPlanner")),
|
||||
},
|
||||
{
|
||||
path: "my-templates",
|
||||
lazy: () =>
|
||||
import("@/pages/my-templates/MyTemplates").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/my-templates/MyTemplates")),
|
||||
},
|
||||
{
|
||||
path: "voice-clone",
|
||||
lazy: () =>
|
||||
import("@/pages/voice-clone/VoiceClone").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/voice-clone/VoiceClone")),
|
||||
},
|
||||
{
|
||||
path: "voice-materials",
|
||||
lazy: () =>
|
||||
import("@/pages/voice-materials/VoiceMaterialLibrary").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/voice-materials/VoiceMaterialLibrary")),
|
||||
},
|
||||
{
|
||||
path: "my-voices",
|
||||
lazy: () =>
|
||||
import("@/pages/my-voices/MyVoices").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/my-voices/MyVoices")),
|
||||
},
|
||||
{
|
||||
path: "accounts",
|
||||
lazy: () =>
|
||||
import("@/pages/accounts/Accounts").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/accounts/Accounts")),
|
||||
},
|
||||
{
|
||||
path: "duplication",
|
||||
lazy: () =>
|
||||
import("@/pages/duplication/DuplicationUpload").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/duplication/DuplicationUpload")),
|
||||
},
|
||||
{
|
||||
path: "duplication/results",
|
||||
lazy: () =>
|
||||
import("@/pages/duplication/DuplicationResults").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/duplication/DuplicationResults")),
|
||||
},
|
||||
{
|
||||
path: "duplication/:id",
|
||||
lazy: () =>
|
||||
import("@/pages/duplication/DuplicationDetail").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/duplication/DuplicationDetail")),
|
||||
},
|
||||
{
|
||||
path: "subscription",
|
||||
lazy: () =>
|
||||
import("@/pages/subscription/Plans").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/subscription/Plans")),
|
||||
},
|
||||
{
|
||||
path: "subscription/upgrade",
|
||||
lazy: () =>
|
||||
import("@/pages/subscription/UpgradeSubscription").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/subscription/UpgradeSubscription")),
|
||||
},
|
||||
{
|
||||
path: "subscription/billing",
|
||||
lazy: () =>
|
||||
import("@/pages/subscription/Billing").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/subscription/Billing")),
|
||||
},
|
||||
{
|
||||
path: "profile",
|
||||
lazy: () =>
|
||||
import("@/pages/profile/Settings").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/profile/Settings")),
|
||||
},
|
||||
{
|
||||
path: "admin",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: () =>
|
||||
import("@/pages/admin/AdminComingSoon").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
|
||||
},
|
||||
{
|
||||
path: "users",
|
||||
lazy: () =>
|
||||
import("@/pages/admin/AdminComingSoon").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
|
||||
},
|
||||
{
|
||||
path: "analytics",
|
||||
lazy: () =>
|
||||
import("@/pages/admin/AdminComingSoon").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
|
||||
},
|
||||
{
|
||||
path: "monitor",
|
||||
lazy: () =>
|
||||
import("@/pages/admin/AdminComingSoon").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
|
||||
},
|
||||
{
|
||||
path: "logs",
|
||||
lazy: () =>
|
||||
import("@/pages/admin/AdminComingSoon").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
lazy: lazyRoute(() => import("@/pages/admin/AdminComingSoon")),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { LazyRouteFunction, RouteObject } from "react-router-dom"
|
||||
import { isChunkLoadError } from "@/utils/chunkLoadError"
|
||||
|
||||
/**
|
||||
* 给 React Router data router 的路由懒加载包一层自动重试:
|
||||
*
|
||||
* - 网络抖动 / 瞬态失败:自动重试最多 2 次(间隔 300ms / 800ms),用户无感恢复
|
||||
* - 发版后旧 chunk 404(chunk 文件名已不存在):重试也拿不到旧文件名,
|
||||
* 重试耗尽后抛出,由全局 ChunkErrorBoundary 捕获并引导整页刷新
|
||||
* (刷新后 index.html 是 no-cache 的,会拿到新 chunk 引用)
|
||||
*/
|
||||
const RETRY_DELAYS_MS = [300, 800]
|
||||
const RETRY_COUNT = RETRY_DELAYS_MS.length
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
export const lazyRoute = (
|
||||
factory: () => Promise<{ default: React.ComponentType }>,
|
||||
): LazyRouteFunction<RouteObject> => {
|
||||
return async () => {
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt <= RETRY_COUNT; attempt++) {
|
||||
try {
|
||||
const mod = await factory()
|
||||
if (!mod.default) {
|
||||
throw new Error("lazyRoute: 目标模块缺少 default 导出")
|
||||
}
|
||||
return { Component: mod.default }
|
||||
} catch (err) {
|
||||
lastError = err
|
||||
// 非 chunk 加载错误(代码 bug 等)立即抛出,不浪费重试
|
||||
if (!isChunkLoadError(err)) throw err
|
||||
if (attempt < RETRY_COUNT) {
|
||||
await sleep(RETRY_DELAYS_MS[attempt])
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
}
|
||||
@@ -242,6 +242,20 @@ describe("assets API", () => {
|
||||
await expect(completeDirectUpload({ name: "test-item" })).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it("请求体携带 file_size(后端同名兜底去重的大小校验依赖它)", async () => {
|
||||
await completeDirectUpload({
|
||||
project_id: "p-1",
|
||||
library_id: "l-1",
|
||||
storage_key: "uploads/k.mp4",
|
||||
file_size: 12345,
|
||||
} as never)
|
||||
const completeCalls = mockPost.mock.calls.filter(
|
||||
([u]: [string]) => u === "/upload/direct/complete",
|
||||
)
|
||||
expect(completeCalls).toHaveLength(1)
|
||||
expect(completeCalls[0][1]).toMatchObject({ file_size: 12345 })
|
||||
})
|
||||
|
||||
it("should reject on API error", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Network error"))
|
||||
mockPost.mockRejectedValue(new Error("Network error"))
|
||||
@@ -259,6 +273,114 @@ describe("assets API", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("uploadAssetDirect skip_transfer 短路", () => {
|
||||
it("prepare 返回 skip_transfer=true → 直接返回 duplicated,不调 transfer/complete", async () => {
|
||||
mockPost.mockImplementation((url: string) => {
|
||||
if (url === "/upload/direct/prepare") {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
upload_url: "https://oss/x",
|
||||
method: "POST",
|
||||
storage_key: "uploads/skip/y.mp4",
|
||||
expires_at: "2099",
|
||||
fields: {},
|
||||
max_size_bytes: 1e9,
|
||||
asset_id: "existing-asset",
|
||||
skip_transfer: true,
|
||||
duplicated: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (url === "/upload/direct/complete") {
|
||||
throw new Error("complete 不应被调用")
|
||||
}
|
||||
throw new Error("unexpected url " + url)
|
||||
})
|
||||
const putSpy = vi.spyOn(globalThis, "XMLHttpRequest")
|
||||
const file = new File(["x"], "x.mp4", { type: "video/mp4" })
|
||||
const result = await uploadAssetDirect({ file, library_id: "lib-1" })
|
||||
expect(result.duplicated).toBe(true)
|
||||
expect(result.asset_id).toBe("existing-asset")
|
||||
// complete 未被调用(mockPost 只记录 prepare,complete 若调用会抛 "不应被调用")
|
||||
const completeCalls = mockPost.mock.calls.filter(
|
||||
([u]: [string]) => u === "/upload/direct/complete",
|
||||
)
|
||||
expect(completeCalls).toHaveLength(0)
|
||||
putSpy.mockRestore()
|
||||
})
|
||||
|
||||
it("prepare 返回 skip_transfer=false → 走老流程(complete 被调用)", async () => {
|
||||
mockPost.mockImplementation((url: string) => {
|
||||
if (url === "/upload/direct/prepare") {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
upload_url: "https://oss/x",
|
||||
method: "POST",
|
||||
storage_key: "uploads/normal/y.mp4",
|
||||
expires_at: "2099",
|
||||
fields: {},
|
||||
max_size_bytes: 1e9,
|
||||
asset_id: "new-asset",
|
||||
},
|
||||
})
|
||||
}
|
||||
if (url === "/upload/direct/complete") {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
storage_key: "uploads/normal/y.mp4",
|
||||
ingest_job_id: "job-1",
|
||||
url: "https://oss/y.mp4",
|
||||
duplicated: false,
|
||||
asset_id: "new-asset",
|
||||
},
|
||||
})
|
||||
}
|
||||
throw new Error("unexpected url " + url)
|
||||
})
|
||||
// mock XMLHttpRequest:send 之后下一 tick 触发 onload 让 transfer 立即成功
|
||||
const origOpen = XMLHttpRequest.prototype.open
|
||||
const origSend = XMLHttpRequest.prototype.send
|
||||
const origSetReadyState = Object.getOwnPropertyDescriptor(
|
||||
XMLHttpRequest.prototype,
|
||||
"readyState",
|
||||
) as PropertyDescriptor | undefined
|
||||
const origStatus = Object.getOwnPropertyDescriptor(XMLHttpRequest.prototype, "status")
|
||||
Object.defineProperty(XMLHttpRequest.prototype, "readyState", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 4,
|
||||
})
|
||||
Object.defineProperty(XMLHttpRequest.prototype, "status", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 200,
|
||||
})
|
||||
XMLHttpRequest.prototype.open = vi.fn() as unknown as typeof origOpen
|
||||
XMLHttpRequest.prototype.send = vi.fn(function (this: XMLHttpRequest) {
|
||||
// 下一 tick 触发 onload(模拟 XHR 异步完成)
|
||||
setTimeout(() => this.onload?.(new ProgressEvent("load")), 0)
|
||||
}) as unknown as typeof origSend
|
||||
const file = new File(["x"], "x.mp4", { type: "video/mp4" })
|
||||
const result = await uploadAssetDirect({ file, library_id: "lib-1" })
|
||||
expect(result.duplicated).toBeFalsy()
|
||||
expect(result.asset_id).toBe("new-asset")
|
||||
const completeCalls = mockPost.mock.calls.filter(
|
||||
([u]: [string]) => u === "/upload/direct/complete",
|
||||
)
|
||||
expect(completeCalls).toHaveLength(1)
|
||||
// complete 请求必须带上 file_size,否则后端同名兜底会误杀同名新视频
|
||||
expect(completeCalls[0][1]).toMatchObject({ file_size: file.size })
|
||||
XMLHttpRequest.prototype.open = origOpen
|
||||
XMLHttpRequest.prototype.send = origSend
|
||||
if (origSetReadyState) {
|
||||
Object.defineProperty(XMLHttpRequest.prototype, "readyState", origSetReadyState)
|
||||
}
|
||||
if (origStatus) {
|
||||
Object.defineProperty(XMLHttpRequest.prototype, "status", origStatus)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("getIngestJob", () => {
|
||||
it("should resolve successfully", async () => {
|
||||
await expect(getIngestJob("test-jobId")).resolves.not.toThrow()
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* 上传去重/幂等工具单测(Issue #1714)
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { describe, it, expect, vi } from "vitest"
|
||||
import {
|
||||
computeFileHash,
|
||||
findDuplicateInQueue,
|
||||
HASH_FULL_READ_LIMIT,
|
||||
HASH_SAMPLE_CHUNK,
|
||||
makeClientUploadId,
|
||||
makeFileFingerprint,
|
||||
} from "@/api/assets/uploadDedup"
|
||||
@@ -83,7 +85,7 @@ describe("computeFileHash", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("computeFileHash 大文件抽样(>256MB)", () => {
|
||||
describe("computeFileHash 大文件抽样(>64MB)", () => {
|
||||
it("抽样路径正常返回 64 位 hex,且大小不同则 hash 不同", async () => {
|
||||
// mock 一个「声称」300MB 的 File:slice 返回小 buffer 即可,不真分配 300MB
|
||||
const makeBig = (declaredSize: number, head: number) => {
|
||||
@@ -98,4 +100,31 @@ describe("computeFileHash 大文件抽样(>256MB)", () => {
|
||||
// 声明大小不同 → 写入的 64 位 size 字段不同 → hash 必须不同(锁定 setBigUint64 路径)
|
||||
expect(h1).not.toBe(h2)
|
||||
})
|
||||
|
||||
it("≤64MB 走全量读取(slice 一次覆盖整个文件)", async () => {
|
||||
const f = new File([new Uint8Array(1024).fill(9)], "full.mp4", { type: "video/mp4" })
|
||||
Object.defineProperty(f, "size", { value: HASH_FULL_READ_LIMIT, configurable: true })
|
||||
const sliceSpy = vi.spyOn(f, "slice")
|
||||
await computeFileHash(f)
|
||||
// 全量路径:唯一一次 slice 为 (0, size)
|
||||
expect(sliceSpy).toHaveBeenCalledTimes(1)
|
||||
expect(sliceSpy).toHaveBeenCalledWith(0, HASH_FULL_READ_LIMIT)
|
||||
sliceSpy.mockRestore()
|
||||
})
|
||||
|
||||
it(">64MB 只读取头尾各 16MB 抽样,绝不整文件读入内存", async () => {
|
||||
const f = new File([new Uint8Array(1024).fill(9)], "big.mp4", { type: "video/mp4" })
|
||||
Object.defineProperty(f, "size", { value: HASH_FULL_READ_LIMIT + 1, configurable: true })
|
||||
const sliceSpy = vi.spyOn(f, "slice")
|
||||
await computeFileHash(f)
|
||||
// 抽样路径:两次 slice —— 头部 (0, 16MB) 与尾部 (size-16MB, size)
|
||||
expect(sliceSpy).toHaveBeenCalledTimes(2)
|
||||
expect(sliceSpy).toHaveBeenNthCalledWith(1, 0, HASH_SAMPLE_CHUNK)
|
||||
expect(sliceSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
HASH_FULL_READ_LIMIT + 1 - HASH_SAMPLE_CHUNK,
|
||||
HASH_FULL_READ_LIMIT + 1,
|
||||
)
|
||||
sliceSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
|
||||
describe("wxLogin 工具", () => {
|
||||
describe("parseWxAuthUrl", () => {
|
||||
it("从微信授权链接解析出 appid/redirect_uri/state(redirect_uri 解码)", async () => {
|
||||
const { parseWxAuthUrl } = await import("@/api/auth/wxLogin")
|
||||
const authUrl =
|
||||
"https://open.weixin.qq.com/connect/qrconnect?appid=wxb7ae80b48e53980d" +
|
||||
"&redirect_uri=https%3A%2F%2Fstaging.xiaoxiajianji.com%2Fauth%2Fwechat%2Fcallback" +
|
||||
"&response_type=code&scope=snsapi_login&state=abc123#wechat_redirect"
|
||||
const params = parseWxAuthUrl(authUrl)
|
||||
expect(params).not.toBeNull()
|
||||
expect(params?.appid).toBe("wxb7ae80b48e53980d")
|
||||
expect(params?.redirect_uri).toBe("https://staging.xiaoxiajianji.com/auth/wechat/callback")
|
||||
expect(params?.state).toBe("abc123")
|
||||
})
|
||||
|
||||
it("链接里缺 state 时回退使用 stateFallback", async () => {
|
||||
const { parseWxAuthUrl } = await import("@/api/auth/wxLogin")
|
||||
const authUrl =
|
||||
"https://open.weixin.qq.com/connect/qrconnect?appid=wx123" +
|
||||
"&redirect_uri=https%3A%2F%2Fexample.com%2Fcb"
|
||||
const params = parseWxAuthUrl(authUrl, "fallback-state")
|
||||
expect(params?.state).toBe("fallback-state")
|
||||
})
|
||||
|
||||
it("缺 appid 或 redirect_uri 时返回 null(调用方应回退整页跳转)", async () => {
|
||||
const { parseWxAuthUrl } = await import("@/api/auth/wxLogin")
|
||||
expect(parseWxAuthUrl("https://open.weixin.qq.com/connect/qrconnect?appid=wx123")).toBeNull()
|
||||
expect(parseWxAuthUrl("not a url")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadWxLoginScript", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
document.head.querySelectorAll("script[src*='wxLogin']").forEach((el) => el.remove())
|
||||
delete (window as unknown as { WxLogin?: unknown }).WxLogin
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("window.WxLogin 已存在时直接复用,不重复插入 script", async () => {
|
||||
const fakeCtor = vi.fn()
|
||||
;(window as unknown as { WxLogin: unknown }).WxLogin = fakeCtor
|
||||
const { loadWxLoginScript } = await import("@/api/auth/wxLogin")
|
||||
const ctor = await loadWxLoginScript()
|
||||
expect(ctor).toBe(fakeCtor)
|
||||
expect(document.head.querySelector("script[src*='wxLogin']")).toBeNull()
|
||||
})
|
||||
|
||||
it("脚本 onerror 时 reject(调用方据此回退整页跳转)", async () => {
|
||||
const { loadWxLoginScript } = await import("@/api/auth/wxLogin")
|
||||
const promise = loadWxLoginScript()
|
||||
const script = document.head.querySelector(
|
||||
"script[src*='wxLogin']",
|
||||
) as HTMLScriptElement | null
|
||||
expect(script).not.toBeNull()
|
||||
script?.dispatchEvent(new Event("error"))
|
||||
await expect(promise).rejects.toThrow(/加载失败/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
|
||||
import { render, screen, fireEvent } from "@testing-library/react"
|
||||
import { Button } from "antd"
|
||||
import { useState } from "react"
|
||||
import ChunkErrorBoundary from "@/components/common/ChunkErrorBoundary"
|
||||
import * as chunkUtils from "@/utils/chunkLoadError"
|
||||
|
||||
// reload 函数 mock 掉(jsdom 不支持真实 window.location.reload)
|
||||
vi.mock("@/utils/chunkLoadError", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/utils/chunkLoadError")>()
|
||||
return {
|
||||
...actual,
|
||||
reloadForChunkError: vi.fn(),
|
||||
goHomeRecover: vi.fn(),
|
||||
}
|
||||
})
|
||||
const { reloadForChunkError, goHomeRecover } = vi.mocked(chunkUtils)
|
||||
|
||||
/** 渲染时直接抛错的子组件 */
|
||||
const Boom: React.FC<{ error: Error }> = ({ error }) => {
|
||||
throw error
|
||||
}
|
||||
|
||||
/** 点击按钮后才抛 chunk 错误的子组件 */
|
||||
const ChunkBoomButton: React.FC = () => {
|
||||
const [boom, setBoom] = useState(false)
|
||||
if (boom) {
|
||||
throw new TypeError("Failed to fetch dynamically imported module: /assets/x.js")
|
||||
}
|
||||
return <Button onClick={() => setBoom(true)}>boom</Button>
|
||||
}
|
||||
|
||||
const renderBoundary = (ui: React.ReactNode) =>
|
||||
render(<ChunkErrorBoundary>{ui}</ChunkErrorBoundary>)
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
// error boundary 捕获后 React 会打 error log,静默掉
|
||||
vi.spyOn(console, "error").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
describe("ChunkErrorBoundary", () => {
|
||||
it("正常渲染 children", () => {
|
||||
renderBoundary(<div>hello-child</div>)
|
||||
expect(screen.getByText("hello-child")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("首次捕获 chunk 错误 → 自动刷新(reloadForChunkError)并显示自动刷新提示", () => {
|
||||
renderBoundary(<ChunkBoomButton />)
|
||||
fireEvent.click(screen.getByText("boom"))
|
||||
expect(reloadForChunkError).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByText(/正在自动刷新/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("已刷新过仍失败 → 不再自动刷新,显示手动兜底按钮", () => {
|
||||
// 模拟"本会话已经自动刷新过一次"
|
||||
sessionStorage.setItem("chunk_error_reloaded_at", String(Date.now()))
|
||||
renderBoundary(
|
||||
<Boom error={new TypeError("Failed to fetch dynamically imported module: /assets/y.js")} />,
|
||||
)
|
||||
expect(reloadForChunkError).not.toHaveBeenCalled()
|
||||
expect(screen.getByText("系统已更新")).toBeInTheDocument()
|
||||
// 点击兜底按钮 → goHomeRecover(跳首页,不刷新当前 URL)
|
||||
fireEvent.click(screen.getByText("刷新并返回首页"))
|
||||
expect(goHomeRecover).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("非 chunk 错误 → 显示通用错误页,不触发 chunk 自动刷新", () => {
|
||||
renderBoundary(<Boom error={new Error("普通业务报错")} />)
|
||||
expect(reloadForChunkError).not.toHaveBeenCalled()
|
||||
expect(screen.getByText("页面出现异常")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react"
|
||||
import WechatQrModal from "@/components/auth/WechatQrModal"
|
||||
|
||||
const { mockWxLoginCtor, mockGetAuthUrl, mockGetBindUrl, mockGetCurrentUser } = vi.hoisted(() => ({
|
||||
mockWxLoginCtor: vi.fn(),
|
||||
mockGetAuthUrl: vi.fn(),
|
||||
mockGetBindUrl: vi.fn(),
|
||||
mockGetCurrentUser: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
getWechatAuthUrl: (...args: unknown[]) => mockGetAuthUrl(...args),
|
||||
getWechatBindUrl: (...args: unknown[]) => mockGetBindUrl(...args),
|
||||
getCurrentUser: (...args: unknown[]) => mockGetCurrentUser(...args),
|
||||
normalizeUser: (u: unknown) => u,
|
||||
}))
|
||||
|
||||
vi.mock("@/api/auth/wxLogin", () => ({
|
||||
loadWxLoginScript: vi.fn(async () => mockWxLoginCtor),
|
||||
parseWxAuthUrl: vi.fn(() => ({
|
||||
appid: "wxb7ae80b48e53980d",
|
||||
redirect_uri: "https://staging.xiaoxiajianji.com/auth/wechat/callback",
|
||||
state: "state-from-url",
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("@/api/auth/tokenRefresh", () => ({
|
||||
scheduleProactiveRefresh: vi.fn(),
|
||||
cancelProactiveRefresh: vi.fn(),
|
||||
}))
|
||||
|
||||
const { mockSetAuth, mockSetUser } = vi.hoisted(() => ({
|
||||
mockSetAuth: vi.fn(),
|
||||
mockSetUser: vi.fn(),
|
||||
}))
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: (selector: (s: unknown) => unknown) =>
|
||||
selector({ setAuth: mockSetAuth, setUser: mockSetUser }),
|
||||
}))
|
||||
|
||||
const AUTH_URL =
|
||||
"https://open.weixin.qq.com/connect/qrconnect?appid=wxb7ae80b48e53980d" +
|
||||
"&redirect_uri=https%3A%2F%2Fstaging.xiaoxiajianji.com%2Fauth%2Fwechat%2Fcallback&state=st123"
|
||||
|
||||
const postMessage = (data: Record<string, unknown>) =>
|
||||
window.dispatchEvent(new MessageEvent("message", { data, origin: window.location.origin }))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetAuthUrl.mockResolvedValue({ auth_url: AUTH_URL, state: "st123" })
|
||||
mockGetBindUrl.mockResolvedValue({ auth_url: AUTH_URL, state: "st123" })
|
||||
mockGetCurrentUser.mockResolvedValue({ id: 1, display_name: "测试用户" })
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe("WechatQrModal", () => {
|
||||
it("open=false 时不渲染弹窗内容", () => {
|
||||
render(<WechatQrModal open={false} scene="login" onClose={vi.fn()} />)
|
||||
expect(screen.queryByText("微信扫码登录")).toBeNull()
|
||||
})
|
||||
|
||||
it("登录场景:open 后请求授权链接、写入 state、用 WxLogin 渲染二维码", async () => {
|
||||
render(<WechatQrModal open scene="login" onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(mockGetAuthUrl).toHaveBeenCalledTimes(1))
|
||||
expect(localStorage.getItem("wechat_state")).toBe("st123")
|
||||
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
|
||||
expect(mockWxLoginCtor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
self_redirect: true,
|
||||
appid: "wxb7ae80b48e53980d",
|
||||
scope: "snsapi_login",
|
||||
state: "state-from-url",
|
||||
redirect_uri: "https://staging.xiaoxiajianji.com/auth/wechat/callback",
|
||||
}),
|
||||
)
|
||||
expect(screen.getByText(/请使用微信扫描二维码登录/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it("绑定场景:请求 bind/url 且写入 wechat_bind_state", async () => {
|
||||
render(<WechatQrModal open scene="bind" onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(mockGetBindUrl).toHaveBeenCalledTimes(1))
|
||||
expect(mockGetAuthUrl).not.toHaveBeenCalled()
|
||||
expect(localStorage.getItem("wechat_bind_state")).toBe("st123")
|
||||
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it("获取授权链接失败时弹窗内展示错误并提供刷新", async () => {
|
||||
mockGetAuthUrl.mockRejectedValueOnce({
|
||||
response: { status: 500, data: { detail: "微信服务内部错误" } },
|
||||
})
|
||||
render(<WechatQrModal open scene="login" onClose={vi.fn()} />)
|
||||
expect(await screen.findByText(/微信服务内部错误/)).toBeTruthy()
|
||||
expect(screen.getByText("刷新二维码")).toBeTruthy()
|
||||
// 点刷新后重新请求
|
||||
fireEvent.click(screen.getByText("刷新二维码"))
|
||||
await waitFor(() => expect(mockGetAuthUrl).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it("登录成功消息:同步登录态并回调 onLoginSuccess(needOnboarding)", async () => {
|
||||
const onSuccess = vi.fn()
|
||||
localStorage.setItem("access_token", "tok-123")
|
||||
render(<WechatQrModal open scene="login" onClose={vi.fn()} onLoginSuccess={onSuccess} />)
|
||||
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
|
||||
|
||||
postMessage({
|
||||
source: "xiaoxia-wechat-qr",
|
||||
scene: "login",
|
||||
success: true,
|
||||
payload: { needOnboarding: true },
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(true))
|
||||
expect(mockGetCurrentUser).toHaveBeenCalled()
|
||||
expect(mockSetAuth).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }), "tok-123", null)
|
||||
})
|
||||
|
||||
it("登录失败消息:弹窗内展示回调页透传的真实原因", async () => {
|
||||
render(<WechatQrModal open scene="login" onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
|
||||
|
||||
postMessage({
|
||||
source: "xiaoxia-wechat-qr",
|
||||
scene: "login",
|
||||
success: false,
|
||||
detail: "微信登录失败:state 已过期或已被使用",
|
||||
})
|
||||
|
||||
expect(await screen.findByText(/state 已过期或已被使用/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it("绑定成功消息:刷新用户并回调 onBindSuccess", async () => {
|
||||
const onBindSuccess = vi.fn()
|
||||
render(<WechatQrModal open scene="bind" onClose={vi.fn()} onBindSuccess={onBindSuccess} />)
|
||||
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
|
||||
|
||||
postMessage({ source: "xiaoxia-wechat-qr", scene: "bind", success: true })
|
||||
|
||||
await waitFor(() => expect(onBindSuccess).toHaveBeenCalledTimes(1))
|
||||
expect(mockSetUser).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("忽略跨源消息和其他场景的消息", async () => {
|
||||
const onSuccess = vi.fn()
|
||||
render(<WechatQrModal open scene="login" onClose={vi.fn()} onLoginSuccess={onSuccess} />)
|
||||
await waitFor(() => expect(mockWxLoginCtor).toHaveBeenCalledTimes(1))
|
||||
|
||||
// 跨源
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: { source: "xiaoxia-wechat-qr", scene: "login", success: true },
|
||||
origin: "https://evil.example.com",
|
||||
}),
|
||||
)
|
||||
// 场景不符(bind 消息发给 login 弹窗)
|
||||
postMessage({ source: "xiaoxia-wechat-qr", scene: "bind", success: true })
|
||||
// 无协议标识
|
||||
postMessage({ foo: "bar" })
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -26,6 +26,8 @@ interface FakeHandle {
|
||||
fields: Record<string, string>
|
||||
max_size_bytes: number
|
||||
asset_id: string
|
||||
duplicated?: boolean
|
||||
skip_transfer?: boolean
|
||||
}
|
||||
transfer: ReturnType<typeof vi.fn>
|
||||
complete: ReturnType<typeof vi.fn>
|
||||
@@ -46,6 +48,8 @@ const makeFakeHandle = (opts: {
|
||||
duplicated?: boolean
|
||||
failTransfer?: boolean
|
||||
completeAuto?: boolean
|
||||
/** prepare 阶段就命中去重:prepare 响应 skip_transfer/duplicated=true */
|
||||
prepareDedup?: boolean
|
||||
}) => {
|
||||
const h: FakeHandle = {
|
||||
prepared: {
|
||||
@@ -56,6 +60,8 @@ const makeFakeHandle = (opts: {
|
||||
fields: {},
|
||||
max_size_bytes: 2_000_000_000,
|
||||
asset_id: opts.id,
|
||||
duplicated: opts.prepareDedup ? true : undefined,
|
||||
skip_transfer: opts.prepareDedup ? true : undefined,
|
||||
},
|
||||
transfer: vi.fn(),
|
||||
complete: vi.fn(),
|
||||
@@ -224,6 +230,11 @@ describe("useAssetUpload", () => {
|
||||
})
|
||||
await waitFor(() => expect(result.current.uploadItems[0].status).toBe("error"))
|
||||
|
||||
// 失败卡片记录失败阶段与完整错误原因(不再只显示"上传失败")
|
||||
const failed = result.current.uploadItems[0]
|
||||
expect(failed.failedStage).toBe("transfer")
|
||||
expect(failed.error).toContain("OSS boom")
|
||||
|
||||
// 重试:重新 prepare(handles[1] 成功)
|
||||
const tempId = result.current.uploadItems[0].tempId
|
||||
await act(async () => {
|
||||
@@ -334,6 +345,9 @@ describe("useAssetUpload", () => {
|
||||
const it = result.current.uploadItems.find((x) => x.tempId === tempId)
|
||||
expect(it?.status).toBe("error")
|
||||
expect(it?.failedStage).toBe("complete")
|
||||
// 卡片同时展示真实失败原因与"重试不会重新上传"提示
|
||||
expect(it?.error).toContain("complete timeout")
|
||||
expect(it?.error).toContain("不会重新上传文件")
|
||||
})
|
||||
|
||||
// 点重试:pump 复用 handle,只再调一次 complete(transfer/prepare 不重复)
|
||||
@@ -352,4 +366,47 @@ describe("useAssetUpload", () => {
|
||||
expect(result.current.uploadItems.find((x) => x.tempId === tempId)?.status).toBe("done")
|
||||
})
|
||||
})
|
||||
|
||||
it("prepare 阶段失败:标记 prepare 阶段并保留后端错误明细", async () => {
|
||||
;(prepareDirectUploadHandle as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 500, data: { detail: "签名服务内部错误" } },
|
||||
message: "Request failed with status code 500",
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
result.current.enqueueUploads([mp4("prep-fail.mp4")])
|
||||
})
|
||||
await waitFor(() => expect(result.current.uploadItems[0]?.status).toBe("error"))
|
||||
const it = result.current.uploadItems[0]
|
||||
expect(it.failedStage).toBe("prepare")
|
||||
expect(it.error).toContain("签名服务内部错误")
|
||||
})
|
||||
it("prepare 返回 skip_transfer=true 时立即跳过 transfer+complete,标记 done+duplicated", async () => {
|
||||
const h = makeFakeHandle({ id: "a-skip", prepareDedup: true })
|
||||
;(prepareDirectUploadHandle as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
async () => h,
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useAssetUpload({ effectiveLibId: "lib-1" }), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
result.current.enqueueUploads([mp4("skip-transfer.mp4")])
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.transfer).not.toHaveBeenCalled()
|
||||
expect(h.complete).not.toHaveBeenCalled()
|
||||
const it = result.current.uploadItems[0]
|
||||
expect(it?.status).toBe("done")
|
||||
expect(it?.duplicated).toBe(true)
|
||||
expect(it?.assetId).toBe("a-skip")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import { render, screen, waitFor, cleanup } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import WechatBindCallback from "@/pages/auth/WechatBindCallback"
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
const mockSetUser = vi.fn()
|
||||
const mockParams = new URLSearchParams({ code: "bind_code", state: "bind_state" })
|
||||
const mockSearchParams = [mockParams] as const
|
||||
|
||||
const localStorageStore: Record<string, string> = {}
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation((key) => localStorageStore[key] || null)
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation((key, val) => {
|
||||
localStorageStore[key] = val
|
||||
})
|
||||
vi.spyOn(Storage.prototype, "removeItem").mockImplementation((key) => {
|
||||
delete localStorageStore[key]
|
||||
})
|
||||
|
||||
let bindError: unknown = null
|
||||
const mockBindResult = { user: { id: "u1", wechat_bound: true } }
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = await vi.importActual("react-router-dom")
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
useSearchParams: () => mockSearchParams,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
bindWechat: vi.fn(async () => {
|
||||
if (bindError) throw bindError
|
||||
return mockBindResult
|
||||
}),
|
||||
normalizeUser: (u: unknown) => u,
|
||||
}))
|
||||
|
||||
vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: (selector: (state: unknown) => unknown) => selector({ setUser: mockSetUser }),
|
||||
}))
|
||||
|
||||
// iframe 场景:默认非 iframe;用例可 mockReturnValue(true)
|
||||
const { mockIsInIframe, mockPostResult } = vi.hoisted(() => ({
|
||||
mockIsInIframe: vi.fn(() => false),
|
||||
mockPostResult: vi.fn(),
|
||||
}))
|
||||
vi.mock("@/components/auth/WechatQrModal/messages", () => ({
|
||||
isInIframe: () => mockIsInIframe(),
|
||||
postWechatQrResult: (...args: unknown[]) => mockPostResult(...args),
|
||||
}))
|
||||
|
||||
const renderPage = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<WechatBindCallback />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
describe("WechatBindCallback Page", () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsInIframe.mockReturnValue(false)
|
||||
bindError = null
|
||||
Array.from(mockParams.keys()).forEach((k) => mockParams.delete(k))
|
||||
mockParams.set("code", "bind_code")
|
||||
mockParams.set("state", "bind_state")
|
||||
localStorageStore.wechat_bind_state = "bind_state"
|
||||
})
|
||||
|
||||
it("绑定成功跳转设置页并携带 success 标记", async () => {
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/app/profile?wechat_bind=success", {
|
||||
replace: true,
|
||||
})
|
||||
})
|
||||
expect(mockSetUser).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("本地无 wechat_bind_state(微信内/跨浏览器)不再误杀,绑定正常完成", async () => {
|
||||
delete localStorageStore.wechat_bind_state
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/app/profile?wechat_bind=success", {
|
||||
replace: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("后端报错(微信已被其他账号绑定)时页面透传真实原因,不静默跳走", async () => {
|
||||
bindError = {
|
||||
isAxiosError: true,
|
||||
response: { status: 409, data: { detail: "该微信已绑定其他账号" } },
|
||||
message: "Request failed with status code 409",
|
||||
}
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/该微信已绑定其他账号/)).toBeTruthy()
|
||||
})
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("缺少 code/state 时提示无效回调", async () => {
|
||||
mockParams.delete("code")
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/无效的回调参数/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("iframe(弹窗内嵌二维码)场景", () => {
|
||||
it("绑定成功时 postMessage 通知父窗口,不做 navigate", async () => {
|
||||
mockIsInIframe.mockReturnValue(true)
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockPostResult).toHaveBeenCalledWith("bind", true)
|
||||
})
|
||||
expect(mockSetUser).toHaveBeenCalled()
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("绑定失败时把真实原因 postMessage 给父窗口", async () => {
|
||||
mockIsInIframe.mockReturnValue(true)
|
||||
bindError = {
|
||||
isAxiosError: true,
|
||||
response: { status: 409, data: { detail: "该微信已绑定其他账号" } },
|
||||
}
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockPostResult).toHaveBeenCalledWith("bind", false, {
|
||||
detail: expect.stringContaining("该微信已绑定其他账号"),
|
||||
})
|
||||
})
|
||||
expect(screen.queryByText(/返回设置/)).toBeNull()
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,11 @@ import WechatCallback from "@/pages/auth/WechatCallback"
|
||||
|
||||
const mockNavigate = vi.fn()
|
||||
const mockSetAuth = vi.fn()
|
||||
const mockSearchParams = [new URLSearchParams({ code: "test_code", state: "test_state" })] as const
|
||||
|
||||
// useSearchParams 返回模块级稳定引用(数组元素同一 URLSearchParams 实例),
|
||||
// 避免每次 render 返回新数组/新实例导致 useEffect 依赖变化重跑
|
||||
const mockParams = new URLSearchParams({ code: "test_code", state: "test_state" })
|
||||
const mockSearchParams = [mockParams] as const
|
||||
const mockAuthState = { setAuth: mockSetAuth }
|
||||
|
||||
// 文件级 localStorage mock(避免每个用例重复 spy 导致链式污染)
|
||||
@@ -20,7 +24,7 @@ vi.spyOn(Storage.prototype, "removeItem").mockImplementation((key) => {
|
||||
|
||||
let mockCallbackResult: Record<string, unknown> = {}
|
||||
let mockCurrentUser: Record<string, unknown> = {}
|
||||
let callbackShouldFail = false
|
||||
let callbackError: unknown = null
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = await vi.importActual("react-router-dom")
|
||||
@@ -33,7 +37,7 @@ vi.mock("react-router-dom", async () => {
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
wechatCallback: vi.fn(async () => {
|
||||
if (callbackShouldFail) throw new Error("fail")
|
||||
if (callbackError) throw callbackError
|
||||
return mockCallbackResult
|
||||
}),
|
||||
getCurrentUser: vi.fn(async () => mockCurrentUser),
|
||||
@@ -49,6 +53,16 @@ vi.mock("@/store/authStore", () => ({
|
||||
useAuthStore: (selector: (state: unknown) => unknown) => selector({ setAuth: mockSetAuth }),
|
||||
}))
|
||||
|
||||
// iframe 场景:默认非 iframe;用例可 mockReturnValue(true)
|
||||
const { mockIsInIframe, mockPostResult } = vi.hoisted(() => ({
|
||||
mockIsInIframe: vi.fn(() => false),
|
||||
mockPostResult: vi.fn(),
|
||||
}))
|
||||
vi.mock("@/components/auth/WechatQrModal/messages", () => ({
|
||||
isInIframe: () => mockIsInIframe(),
|
||||
postWechatQrResult: (...args: unknown[]) => mockPostResult(...args),
|
||||
}))
|
||||
|
||||
const renderPage = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
@@ -63,7 +77,12 @@ describe("WechatCallback Page", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
callbackShouldFail = false
|
||||
mockIsInIframe.mockReturnValue(false)
|
||||
callbackError = null
|
||||
// 默认正常回调参数;用例可改写 mockParams 模拟 error 重定向
|
||||
Array.from(mockParams.keys()).forEach((k) => mockParams.delete(k))
|
||||
mockParams.set("code", "test_code")
|
||||
mockParams.set("state", "test_state")
|
||||
localStorageStore.wechat_state = "test_state"
|
||||
mockCallbackResult = {
|
||||
access_token: "at",
|
||||
@@ -103,20 +122,47 @@ describe("WechatCallback Page", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("state 不匹配显示安全错误", async () => {
|
||||
localStorageStore.wechat_state = "other_state"
|
||||
it("本地无 wechat_state(微信内打开/跨浏览器场景)不再误杀,正常完成登录", async () => {
|
||||
delete localStorageStore.wechat_state
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("安全校验失败,请重新登录")).toBeTruthy()
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true })
|
||||
})
|
||||
// state 已被清理
|
||||
expect(localStorageStore.wechat_state).toBeUndefined()
|
||||
})
|
||||
|
||||
it("后端返回 detail 错误时,页面透传真实原因(不再吞成通用提示)", async () => {
|
||||
callbackError = {
|
||||
isAxiosError: true,
|
||||
response: { status: 400, data: { detail: "微信授权码已过期,请重新扫码" } },
|
||||
message: "Request failed with status code 400",
|
||||
}
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/微信授权码已过期,请重新扫码/)).toBeTruthy()
|
||||
})
|
||||
expect(screen.queryByText(/^微信登录失败,请重试$/)).toBeNull()
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("微信重定向带 error(用户拒绝授权)时展示授权失败原因", async () => {
|
||||
for (const k of Array.from(mockParams.keys())) mockParams.delete(k)
|
||||
mockParams.set("error", "access_denied")
|
||||
mockParams.set("error_description", "The+user+denied+the+request")
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/微信授权失败/)).toBeTruthy()
|
||||
expect(screen.getByText(/access_denied/)).toBeTruthy()
|
||||
})
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("接口失败显示错误提示", async () => {
|
||||
callbackShouldFail = true
|
||||
it("缺少 code/state 参数时提示无效回调", async () => {
|
||||
mockParams.delete("code")
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("微信登录失败,请重试")).toBeTruthy()
|
||||
expect(screen.getByText(/无效的回调参数/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -124,4 +170,54 @@ describe("WechatCallback Page", () => {
|
||||
renderPage()
|
||||
expect(screen.getByText("微信登录中...")).toBeTruthy()
|
||||
})
|
||||
|
||||
describe("iframe(弹窗内嵌二维码)场景", () => {
|
||||
it("登录成功时 postMessage 通知父窗口(needOnboarding=false),不做 navigate", async () => {
|
||||
mockIsInIframe.mockReturnValue(true)
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockPostResult).toHaveBeenCalledWith("login", true, { needOnboarding: false })
|
||||
})
|
||||
expect(mockSetAuth).toHaveBeenCalled()
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("新用户成功时上报 needOnboarding=true", async () => {
|
||||
mockIsInIframe.mockReturnValue(true)
|
||||
mockCallbackResult = { access_token: "at", refresh_token: "rt", is_new_user: true }
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockPostResult).toHaveBeenCalledWith("login", true, { needOnboarding: true })
|
||||
})
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("后端报错时把真实原因 postMessage 给父窗口,页面不渲染错误/按钮", async () => {
|
||||
mockIsInIframe.mockReturnValue(true)
|
||||
callbackError = {
|
||||
isAxiosError: true,
|
||||
response: { status: 400, data: { detail: "state 已过期或已被使用" } },
|
||||
}
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockPostResult).toHaveBeenCalledWith("login", false, {
|
||||
detail: expect.stringContaining("state 已过期或已被使用"),
|
||||
})
|
||||
})
|
||||
expect(screen.queryByText(/返回登录/)).toBeNull()
|
||||
expect(mockNavigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("微信重定向 error(拒绝授权)在 iframe 内也上报父窗口", async () => {
|
||||
mockIsInIframe.mockReturnValue(true)
|
||||
for (const k of Array.from(mockParams.keys())) mockParams.delete(k)
|
||||
mockParams.set("error", "access_denied")
|
||||
renderPage()
|
||||
await waitFor(() => {
|
||||
expect(mockPostResult).toHaveBeenCalledWith("login", false, {
|
||||
detail: expect.stringContaining("access_denied"),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -83,6 +83,12 @@ describe("WechatOnboarding 昵称引导页", () => {
|
||||
expect(screen.queryByText("进入小虾智剪")).toBeNull()
|
||||
})
|
||||
|
||||
it("昵称输入框不预填,必须用户自己输入", () => {
|
||||
renderPage()
|
||||
expect(screen.getByText("欢迎使用微信登录,请先设置您的昵称")).toBeTruthy()
|
||||
expect((screen.getByPlaceholderText("请输入您的昵称") as HTMLInputElement).value).toBe("")
|
||||
})
|
||||
|
||||
it("新用户可见昵称表单并能提交", async () => {
|
||||
renderPage()
|
||||
expect(screen.getByText("欢迎使用微信登录,请先设置您的昵称")).toBeTruthy()
|
||||
@@ -116,6 +122,54 @@ describe("WechatOnboarding 昵称引导页", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("连点提交按钮只触发一次请求(防重复提交)", async () => {
|
||||
// mutation 挂起不立即完成,模拟慢网络下连续双击
|
||||
let resolveSubmit: (v: unknown) => void = () => {}
|
||||
updateProfileMock = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSubmit = resolve
|
||||
}),
|
||||
)
|
||||
renderPage()
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入您的昵称"), {
|
||||
target: { value: "小虾用户" },
|
||||
})
|
||||
const btn = screen.getByText("进入小虾智剪")
|
||||
fireEvent.click(btn)
|
||||
// 第一次点击后立即再点(此时重渲染/loading 可能还没生效)
|
||||
fireEvent.click(btn)
|
||||
fireEvent.click(btn)
|
||||
await waitFor(() => {
|
||||
expect(updateProfileMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
// 释放挂起的 Promise,避免泄漏
|
||||
resolveSubmit({ id: "u1", display_name: "小虾用户", profile_completed: true })
|
||||
})
|
||||
|
||||
it("提交失败后守卫复位,允许再次提交", async () => {
|
||||
updateProfileMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 500, data: { detail: "服务内部错误" } },
|
||||
})
|
||||
.mockResolvedValueOnce({ id: "u1", display_name: "小虾用户", profile_completed: true })
|
||||
renderPage()
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入您的昵称"), {
|
||||
target: { value: "小虾用户" },
|
||||
})
|
||||
fireEvent.click(screen.getByText("进入小虾智剪"))
|
||||
await waitFor(() => {
|
||||
expect(updateProfileMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
// 失败后再点一次,应能重新提交
|
||||
fireEvent.click(screen.getByText("进入小虾智剪"))
|
||||
await waitFor(() => {
|
||||
expect(updateProfileMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
it("提交失败显示错误且不跳转", async () => {
|
||||
updateProfileMock = vi.fn(async () => {
|
||||
throw new Error("500")
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* CanvasPreviewGrid 单测(Issue #1741)
|
||||
*
|
||||
* 验证:
|
||||
* - N=3 时每个变体都拿到 voiceAudioUrl(修复前仅 index 0 有,视频 2/3 无声)
|
||||
* - 每个变体都收到 activePlayToken / onPlayTokenChange(播放互斥接线)
|
||||
* - 某个实例上报播放 → 所有实例的 activePlayToken 变为该实例(其他实例收到 token≠自身,自动暂停)
|
||||
* - 实例上报暂停(null)→ 播放权释放
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest"
|
||||
import { render, fireEvent } from "@testing-library/react"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
const playerCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
|
||||
|
||||
vi.mock("@/pages/generate/components/FrontendPreviewPlayer", () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
playerCalls.push(props)
|
||||
const seed = props.variantSeed as number
|
||||
const token = props.activePlayToken as number | null
|
||||
const change = props.onPlayTokenChange as (t: number | null) => void
|
||||
return (
|
||||
<div data-testid={`player-${seed}`}>
|
||||
<span data-testid={`voice-${seed}`}>{props.voiceAudioUrl ? "has-voice" : "no-voice"}</span>
|
||||
<span data-testid={`token-${seed}`}>{token == null ? "none" : String(token)}</span>
|
||||
<button type="button" onClick={() => change(seed)}>
|
||||
play-{seed}
|
||||
</button>
|
||||
<button type="button" onClick={() => change(null)}>
|
||||
pause-{seed}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
import CanvasPreviewGrid from "@/pages/generate/components/CanvasPreviewGrid"
|
||||
|
||||
function makeAsset(id: string): AssetItem {
|
||||
return {
|
||||
id,
|
||||
library_id: "lib-1",
|
||||
name: `${id}.mp4`,
|
||||
storage_key: `media/${id}.mp4`,
|
||||
file_url: `https://cdn.example.com/${id}.mp4`,
|
||||
mime_type: "video/mp4",
|
||||
metadata: { duration: 10 },
|
||||
duration: 10,
|
||||
}
|
||||
}
|
||||
|
||||
const titleSettings = {
|
||||
size: 36,
|
||||
font: "思源黑体",
|
||||
color: "#fff",
|
||||
position: "bottom" as const,
|
||||
bold: false,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
posX: null,
|
||||
posY: null,
|
||||
}
|
||||
|
||||
function renderGrid(count = 3) {
|
||||
playerCalls.length = 0
|
||||
return render(
|
||||
<CanvasPreviewGrid
|
||||
count={count}
|
||||
assets={[makeAsset("a1"), makeAsset("a2"), makeAsset("a3")]}
|
||||
template={null}
|
||||
videoRatio="9:16"
|
||||
titles={["标题1", "标题2", "标题3"]}
|
||||
titleSettings={titleSettings}
|
||||
voiceAudioUrl="https://cdn.example.com/tts.mp3"
|
||||
selectedIds={[0, 1, 2]}
|
||||
onToggleSelect={() => {}}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe("CanvasPreviewGrid 配音与播放互斥 (#1741)", () => {
|
||||
it("N=3 时每个变体都拿到 voiceAudioUrl(不再只有 index 0)", () => {
|
||||
renderGrid(3)
|
||||
expect(playerCalls).toHaveLength(3)
|
||||
playerCalls.forEach((p, i) => {
|
||||
expect(p.voiceAudioUrl).toBe("https://cdn.example.com/tts.mp3")
|
||||
expect(p.variantSeed).toBe(i + 1)
|
||||
})
|
||||
})
|
||||
|
||||
it("每个变体都接线 activePlayToken / onPlayTokenChange", () => {
|
||||
renderGrid(3)
|
||||
playerCalls.forEach((p) => {
|
||||
expect(p.activePlayToken).toBeNull()
|
||||
expect(typeof p.onPlayTokenChange).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
it("点击实例2播放:所有实例 activePlayToken 变为 2(其他实例自动暂停)", () => {
|
||||
const { getByTestId } = renderGrid(3)
|
||||
fireEvent.click(getByTestId("player-2").querySelector("button")!)
|
||||
expect(getByTestId("token-1").textContent).toBe("2")
|
||||
expect(getByTestId("token-2").textContent).toBe("2")
|
||||
expect(getByTestId("token-3").textContent).toBe("2")
|
||||
})
|
||||
|
||||
it("正在播放实例上报暂停后,播放权释放(token 回 null)", () => {
|
||||
const { getByTestId } = renderGrid(3)
|
||||
fireEvent.click(getByTestId("player-3").querySelector("button")!)
|
||||
expect(getByTestId("token-1").textContent).toBe("3")
|
||||
|
||||
fireEvent.click(getByTestId("player-3").querySelectorAll("button")[1])
|
||||
expect(getByTestId("token-3").textContent).toBe("none")
|
||||
})
|
||||
|
||||
it("渲染 N 个勾选标签(视频1..N)且配音状态全部 has-voice", () => {
|
||||
const { getByTestId } = renderGrid(3)
|
||||
expect(getByTestId("voice-1").textContent).toBe("has-voice")
|
||||
expect(getByTestId("voice-2").textContent).toBe("has-voice")
|
||||
expect(getByTestId("voice-3").textContent).toBe("has-voice")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* FrontendPreviewPlayer 音频行为单测(Issue #1741)
|
||||
*
|
||||
* useSegmentScheduler/useCanvasPlayer 用 mock 控制播放态,专注验证本组件的音频逻辑:
|
||||
* - 有配音时 video 保持 muted(素材原声不与配音混音)
|
||||
* - 无配音时 video 不 muted(素材原声兜底,保证任何情况下播放有声)
|
||||
* - 静音按钮:默认有声;点击后切 muted,aria-label 与图标切换
|
||||
* - 批量播放互斥:activePlayToken 变为其他实例且本实例在播放时,调用 pause
|
||||
* - 点击播放/暂停时上报播放权(onPlayTokenChange)
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { render, screen, fireEvent } from "@testing-library/react"
|
||||
import FrontendPreviewPlayer from "@/pages/generate/components/FrontendPreviewPlayer"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
isPlaying: false,
|
||||
pause: vi.fn(),
|
||||
togglePlayPause: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/generate/hooks/useSegmentScheduler", () => ({
|
||||
useSegmentScheduler: () => ({
|
||||
isPlaying: mocks.isPlaying,
|
||||
currentTime: 0,
|
||||
totalDuration: 20,
|
||||
currentSegmentIndex: 0,
|
||||
canPlay: true,
|
||||
togglePlayPause: mocks.togglePlayPause,
|
||||
seekTo: vi.fn(),
|
||||
pause: mocks.pause,
|
||||
videoRefs: { current: [] as (HTMLVideoElement | null)[] },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@/pages/generate/hooks/useCanvasPlayer", () => ({
|
||||
useCanvasPlayer: () => ({
|
||||
state: {
|
||||
isPlaying: false,
|
||||
isReady: false,
|
||||
isBuffering: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
errorMessage: "",
|
||||
hasDecodeError: false,
|
||||
},
|
||||
controls: { play: vi.fn(), pause: vi.fn(), seek: vi.fn() },
|
||||
}),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.isPlaying = false
|
||||
mocks.pause.mockClear()
|
||||
mocks.togglePlayPause.mockClear()
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
function makeAsset(id: string): AssetItem {
|
||||
return {
|
||||
id,
|
||||
library_id: "lib-1",
|
||||
name: `${id}.mp4`,
|
||||
storage_key: `media/${id}.mp4`,
|
||||
file_url: `https://cdn.example.com/${id}.mp4`,
|
||||
mime_type: "video/mp4",
|
||||
metadata: { duration: 10, width: 1080, height: 1920 },
|
||||
duration: 10,
|
||||
}
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
assets: [makeAsset("a1"), makeAsset("a2")],
|
||||
template: null,
|
||||
videoRatio: "9:16",
|
||||
ready: true,
|
||||
}
|
||||
|
||||
function videos(): HTMLVideoElement[] {
|
||||
return Array.from(document.querySelectorAll("video"))
|
||||
}
|
||||
|
||||
describe("FrontendPreviewPlayer 音频行为 (#1741)", () => {
|
||||
it("有配音时 video 保持 muted(素材原声不与配音混音)", () => {
|
||||
render(<FrontendPreviewPlayer {...baseProps} voiceAudioUrl="https://cdn.example.com/tts.mp3" />)
|
||||
expect(videos()).toHaveLength(2)
|
||||
videos().forEach((v) => expect(v.muted).toBe(true))
|
||||
})
|
||||
|
||||
it("无配音时 video 不 muted(素材原声兜底)", () => {
|
||||
render(<FrontendPreviewPlayer {...baseProps} />)
|
||||
videos().forEach((v) => expect(v.muted).toBe(false))
|
||||
})
|
||||
|
||||
it("无配音时点静音按钮,video 切换为 muted;再点恢复", () => {
|
||||
render(<FrontendPreviewPlayer {...baseProps} />)
|
||||
const vs = videos()
|
||||
expect(vs[0].muted).toBe(false)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "静音" }))
|
||||
videos().forEach((v) => expect(v.muted).toBe(true))
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "取消静音" }))
|
||||
videos().forEach((v) => expect(v.muted).toBe(false))
|
||||
})
|
||||
|
||||
it("批量播放互斥:token 变为其他实例且本实例在播放时调用 pause", () => {
|
||||
mocks.isPlaying = true
|
||||
const { rerender } = render(
|
||||
<FrontendPreviewPlayer
|
||||
{...baseProps}
|
||||
variantSeed={2}
|
||||
activePlayToken={2}
|
||||
onPlayTokenChange={() => {}}
|
||||
compact
|
||||
/>,
|
||||
)
|
||||
expect(mocks.pause).not.toHaveBeenCalled()
|
||||
|
||||
// 播放权切给实例 3
|
||||
rerender(
|
||||
<FrontendPreviewPlayer
|
||||
{...baseProps}
|
||||
variantSeed={2}
|
||||
activePlayToken={3}
|
||||
onPlayTokenChange={() => {}}
|
||||
compact
|
||||
/>,
|
||||
)
|
||||
expect(mocks.pause).toHaveBeenCalledTimes(1)
|
||||
|
||||
// token 切回自己:不重复暂停
|
||||
rerender(
|
||||
<FrontendPreviewPlayer
|
||||
{...baseProps}
|
||||
variantSeed={2}
|
||||
activePlayToken={2}
|
||||
onPlayTokenChange={() => {}}
|
||||
compact
|
||||
/>,
|
||||
)
|
||||
expect(mocks.pause).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("未播放时 token 变化不触发暂停(effect 仅在本实例播放时生效)", () => {
|
||||
// mocks.isPlaying = false(beforeEach 重置)
|
||||
const { rerender } = render(
|
||||
<FrontendPreviewPlayer {...baseProps} variantSeed={1} activePlayToken={1} compact />,
|
||||
)
|
||||
rerender(<FrontendPreviewPlayer {...baseProps} variantSeed={1} activePlayToken={2} compact />)
|
||||
expect(mocks.pause).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("暂停状态下点击播放按钮:上报播放权为自身 variantSeed 并触发播放", () => {
|
||||
const onToken = vi.fn()
|
||||
render(
|
||||
<FrontendPreviewPlayer {...baseProps} variantSeed={3} onPlayTokenChange={onToken} compact />,
|
||||
)
|
||||
// 暂停态有两个图标播放按钮(中央大按钮 + 控制条按钮),均调 handleTogglePlay,点中央那个
|
||||
const playButtons = screen.getAllByRole("button").filter((b) => !b.getAttribute("aria-label"))
|
||||
expect(playButtons.length).toBeGreaterThanOrEqual(1)
|
||||
fireEvent.click(playButtons[0])
|
||||
expect(onToken).toHaveBeenCalledWith(3)
|
||||
expect(mocks.togglePlayPause).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("每个卡片都渲染独立静音按钮", () => {
|
||||
render(
|
||||
<div>
|
||||
<FrontendPreviewPlayer {...baseProps} variantSeed={1} compact />
|
||||
<FrontendPreviewPlayer {...baseProps} variantSeed={2} compact />
|
||||
<FrontendPreviewPlayer {...baseProps} variantSeed={3} compact />
|
||||
</div>,
|
||||
)
|
||||
expect(screen.getAllByRole("button", { name: "静音" })).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 智能匹配候选池数量规则单测(#1744)
|
||||
* 候选池从「总时长/15秒」扩大到至少「片段数 × 3」,供批量变体独立选片避让。
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { computeLimitFromSegments } from "@/pages/generate/hooks/step2-materials/useSmartMatch"
|
||||
import type { TemplateSegment } from "@/api/templates/types"
|
||||
|
||||
function seg(duration_min: number, duration_max = duration_min): TemplateSegment {
|
||||
return { duration_min, duration_max } as TemplateSegment
|
||||
}
|
||||
|
||||
describe("computeLimitFromSegments (#1744 候选池×3)", () => {
|
||||
it("无 segments 时返回兜底 30", () => {
|
||||
expect(computeLimitFromSegments(undefined)).toBe(30)
|
||||
expect(computeLimitFromSegments([])).toBe(30)
|
||||
})
|
||||
|
||||
it("候选池至少为片段数 × 3(短片段场景)", () => {
|
||||
// 5 个片段,每个 2 秒:旧规则 max(5, ceil(10/15)=1)=5;新规则 5×3=15
|
||||
const segs = Array.from({ length: 5 }, () => seg(2))
|
||||
expect(computeLimitFromSegments(segs)).toBe(15)
|
||||
})
|
||||
|
||||
it("10 个片段 → 至少 30 个候选", () => {
|
||||
const segs = Array.from({ length: 10 }, () => seg(3))
|
||||
expect(computeLimitFromSegments(segs)).toBe(30)
|
||||
})
|
||||
|
||||
it("长时长场景取「时长/15秒」与「片段数×3」的较大值", () => {
|
||||
// 3 个片段各 300 秒:3×3=9 vs ceil(900/15)=60 → 60
|
||||
const segs = Array.from({ length: 3 }, () => seg(300))
|
||||
expect(computeLimitFromSegments(segs)).toBe(60)
|
||||
})
|
||||
|
||||
it("上限钳制 200(后端 limit 上限)", () => {
|
||||
const segs = Array.from({ length: 100 }, () => seg(10))
|
||||
expect(computeLimitFromSegments(segs)).toBe(200)
|
||||
})
|
||||
|
||||
it("总时长为 0 时仍保证片段数×3", () => {
|
||||
const segs = Array.from({ length: 4 }, () => seg(0))
|
||||
expect(computeLimitFromSegments(segs)).toBe(12)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* TitleLibraryAutoComplete 单测(Issue #1737)
|
||||
*
|
||||
* 覆盖:
|
||||
* - 聚焦空输入框 → 下拉立即展开,展示标题库全部标题(原生 AutoComplete 聚焦不展开,此为本工单核心修复)
|
||||
* - 输入关键词 → 下拉只显示匹配项
|
||||
* - 点击下拉项 → onChange 回填所选标题
|
||||
* - 自由输入自定义标题 → onChange 正常透传,不被下拉干扰
|
||||
* - 标题库为空 → 聚焦不展开(不出"暂无数据"空壳)
|
||||
* - 选中后下拉关闭
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest"
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import TitleLibraryAutoComplete from "@/pages/generate/components/title/TitleLibraryAutoComplete"
|
||||
|
||||
const OPTIONS = [
|
||||
{ label: "永康这家面馆绝了", value: "永康这家面馆绝了" },
|
||||
{ label: "永康美食探店vlog", value: "永康美食探店vlog" },
|
||||
{ label: "萌宠日常第一天", value: "萌宠日常第一天" },
|
||||
]
|
||||
|
||||
function renderBox(initialValue = "", opts = OPTIONS) {
|
||||
const onChange = vi.fn()
|
||||
const result = render(
|
||||
<TitleLibraryAutoComplete
|
||||
value={initialValue}
|
||||
onChange={onChange}
|
||||
options={opts}
|
||||
placeholder="输入或从标题库选择"
|
||||
/>,
|
||||
)
|
||||
return { onChange, ...result }
|
||||
}
|
||||
|
||||
/** 聚焦输入框(combobox role) */
|
||||
function focusInput() {
|
||||
const input = screen.getByRole("combobox") as HTMLInputElement
|
||||
fireEvent.focus(input)
|
||||
return input
|
||||
}
|
||||
|
||||
/** 取下拉中实际可见的选项(rc-virtual-list 渲染为 .ant-select-item-option;role=option 的 listbox 是 a11y 哨兵) */
|
||||
function getVisibleOptions(): HTMLElement[] {
|
||||
const dropdown = document.querySelector(".ant-select-dropdown:not(.ant-select-dropdown-hidden)")
|
||||
if (!dropdown) return []
|
||||
return Array.from(dropdown.querySelectorAll(".ant-select-item-option")) as HTMLElement[]
|
||||
}
|
||||
|
||||
describe("TitleLibraryAutoComplete (#1737)", () => {
|
||||
it("聚焦空输入框时下拉展开并展示标题库全部标题", async () => {
|
||||
renderBox()
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
|
||||
|
||||
focusInput()
|
||||
|
||||
await screen.findByRole("listbox")
|
||||
await waitFor(() => expect(getVisibleOptions()).toHaveLength(3))
|
||||
const options = getVisibleOptions()
|
||||
expect(options[0]).toHaveTextContent("永康这家面馆绝了")
|
||||
expect(options[2]).toHaveTextContent("萌宠日常第一天")
|
||||
})
|
||||
|
||||
it("输入关键词时下拉只显示匹配项", async () => {
|
||||
const user = userEvent.setup()
|
||||
renderBox()
|
||||
const input = screen.getByRole("combobox")
|
||||
await user.click(input)
|
||||
await screen.findByRole("listbox")
|
||||
|
||||
await user.type(input, "永康")
|
||||
await waitFor(() => expect(getVisibleOptions()).toHaveLength(2))
|
||||
const options = getVisibleOptions()
|
||||
expect(options.every((o) => o.textContent?.includes("永康"))).toBe(true)
|
||||
})
|
||||
|
||||
it("点击下拉项后 onChange 回填标题且下拉关闭", async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onChange } = renderBox()
|
||||
const input = screen.getByRole("combobox") as HTMLInputElement
|
||||
await user.click(input)
|
||||
await screen.findByRole("listbox")
|
||||
|
||||
await user.click(screen.getByText("萌宠日常第一天"))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith("萌宠日常第一天")
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it("自由输入自定义标题时 onChange 正常透传(不被下拉干扰)", async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onChange } = renderBox()
|
||||
const input = screen.getByRole("combobox")
|
||||
await user.click(input)
|
||||
|
||||
await user.type(input, "我自己编的标题XYZ")
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith("我自己编的标题XYZ")
|
||||
})
|
||||
// 输入无匹配关键词,下拉无 option 时不阻塞输入
|
||||
expect(input).toHaveValue("我自己编的标题XYZ")
|
||||
})
|
||||
|
||||
it("标题库为空时聚焦不展开下拉", async () => {
|
||||
renderBox("", [])
|
||||
focusInput()
|
||||
// 等一帧确认没有 listbox
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("渲染下拉箭头图标作为可选择提示", () => {
|
||||
const { container } = renderBox()
|
||||
// antd 后缀图标在 .ant-select-arrow 内
|
||||
expect(container.querySelector(".ant-select-arrow")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("有初始值时输入框正常展示", () => {
|
||||
renderBox("已有标题")
|
||||
expect(screen.getByRole("combobox")).toHaveValue("已有标题")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* useBatchVariantPlans 单测(#1744)
|
||||
* - 批量(N>1)时向后端申请变体计划,返回 clips/planIds
|
||||
* - 404(端点未上线)静默降级:ready=false,不 toast
|
||||
* - 400(素材不足)静默降级(后端 detail 由全局拦截器 toast)
|
||||
* - N=1 / 无素材不发请求
|
||||
* - 入参变化(素材/数量)重新申请;未变化不重复请求
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { renderHook, waitFor } from "@testing-library/react"
|
||||
import { useBatchVariantPlans } from "@/pages/generate/hooks/useBatchVariantPlans"
|
||||
import type { EditPlanClip } from "@/api/template-editor"
|
||||
|
||||
vi.mock("@/api/generation/variantPlans", () => ({
|
||||
createBatchVariantPlans: vi.fn(),
|
||||
}))
|
||||
|
||||
// 轻量 mock:hook 只用到 message.info(避免全量 importActual antd 的开销)
|
||||
vi.mock("antd", () => ({
|
||||
message: { info: vi.fn(), error: vi.fn(), warning: vi.fn(), success: vi.fn(), loading: vi.fn() },
|
||||
}))
|
||||
|
||||
import { createBatchVariantPlans } from "@/api/generation/variantPlans"
|
||||
import { message } from "antd"
|
||||
|
||||
const mockCreate = vi.mocked(createBatchVariantPlans)
|
||||
|
||||
function makeClip(
|
||||
partial: Partial<EditPlanClip> & { asset_id: string; order: number },
|
||||
): EditPlanClip {
|
||||
return {
|
||||
id: `clip-${partial.asset_id}-${partial.order}`,
|
||||
plan_id: "plan-x",
|
||||
clip_type: "main",
|
||||
start_time: 0,
|
||||
duration: 5,
|
||||
text_content: "",
|
||||
transition_effect: "",
|
||||
transition_duration: 0,
|
||||
playback_speed: 1,
|
||||
status: "ready",
|
||||
config: {},
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function buildResp(count: number) {
|
||||
return {
|
||||
total: count,
|
||||
items: Array.from({ length: count }, (_, i) => ({
|
||||
variant_index: i,
|
||||
plan_id: `plan-${i}`,
|
||||
clips: [makeClip({ asset_id: `a${(i % 3) + 1}`, order: 0, start_time: i * 2 })],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCreate.mockReset()
|
||||
})
|
||||
|
||||
describe("useBatchVariantPlans", () => {
|
||||
it("批量 N=3 时申请变体计划并返回按索引对齐的 clips/planIds", async () => {
|
||||
mockCreate.mockResolvedValueOnce(buildResp(3))
|
||||
const { result } = renderHook(() =>
|
||||
useBatchVariantPlans({
|
||||
enabled: true,
|
||||
count: 3,
|
||||
templateId: "tpl-1",
|
||||
assetIds: ["a1", "a2", "a3"],
|
||||
sourcePlanId: "plan-src",
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.ready).toBe(true))
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1)
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
template_id: "tpl-1",
|
||||
asset_ids: ["a1", "a2", "a3"],
|
||||
count: 3,
|
||||
source_edit_plan_id: "plan-src",
|
||||
})
|
||||
expect(result.current.planIdsByVariant).toEqual(["plan-0", "plan-1", "plan-2"])
|
||||
expect(result.current.clipsByVariant[1]?.[0]?.start_time).toBe(2)
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
it("sourcePlanId 为空时不传 source_edit_plan_id 字段", async () => {
|
||||
mockCreate.mockResolvedValueOnce(buildResp(2))
|
||||
const { result } = renderHook(() =>
|
||||
useBatchVariantPlans({ enabled: true, count: 2, templateId: "tpl-1", assetIds: ["a1"] }),
|
||||
)
|
||||
await waitFor(() => expect(result.current.ready).toBe(true))
|
||||
const arg = mockCreate.mock.calls[0][0]
|
||||
expect(arg).not.toHaveProperty("source_edit_plan_id")
|
||||
})
|
||||
|
||||
it("404(端点未上线)静默降级:ready=false 且不弹任何提示", async () => {
|
||||
mockCreate.mockRejectedValueOnce({ response: { status: 404 } })
|
||||
const { result } = renderHook(() =>
|
||||
useBatchVariantPlans({ enabled: true, count: 3, templateId: "tpl-1", assetIds: ["a1"] }),
|
||||
)
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.ready).toBe(false)
|
||||
expect(result.current.clipsByVariant).toEqual([])
|
||||
expect(message.info).not.toHaveBeenCalled()
|
||||
expect(message.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("400(素材不足)静默降级,不重复弹错(后端 detail 全局拦截器已 toast)", async () => {
|
||||
mockCreate.mockRejectedValueOnce({ response: { status: 400, data: { detail: "素材不足" } } })
|
||||
const { result } = renderHook(() =>
|
||||
useBatchVariantPlans({ enabled: true, count: 3, templateId: "tpl-1", assetIds: ["a1"] }),
|
||||
)
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.ready).toBe(false)
|
||||
expect(message.info).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("网络错误降级并仅提示一次", async () => {
|
||||
mockCreate.mockRejectedValue(new Error("Network Error"))
|
||||
const { result, rerender } = renderHook(
|
||||
(props: { count: number }) =>
|
||||
useBatchVariantPlans({
|
||||
enabled: true,
|
||||
count: props.count,
|
||||
templateId: "tpl-1",
|
||||
assetIds: ["a1"],
|
||||
}),
|
||||
{ initialProps: { count: 3 } },
|
||||
)
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.ready).toBe(false)
|
||||
expect(message.info).toHaveBeenCalledTimes(1)
|
||||
|
||||
// 入参变化触发第二次失败,warnedRef 保证不再重复提示
|
||||
rerender({ count: 4 })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(message.info).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("N=1 不发请求(单视频零回归)", () => {
|
||||
renderHook(() =>
|
||||
useBatchVariantPlans({ enabled: true, count: 1, templateId: "tpl-1", assetIds: ["a1"] }),
|
||||
)
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("无素材/enabled=false 不发请求", () => {
|
||||
const { rerender } = renderHook(
|
||||
(props: { enabled: boolean; ids: string[] }) =>
|
||||
useBatchVariantPlans({
|
||||
enabled: props.enabled,
|
||||
count: 3,
|
||||
templateId: "tpl-1",
|
||||
assetIds: props.ids,
|
||||
}),
|
||||
{ initialProps: { enabled: true, ids: [] as string[] } },
|
||||
)
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
rerender({ enabled: false, ids: ["a1"] })
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("响应乱序/越界 variant_index 被归一化到按索引对齐", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
total: 2,
|
||||
items: [
|
||||
{ variant_index: 99, plan_id: "plan-bad", clips: [] },
|
||||
{ variant_index: 1, plan_id: "plan-1", clips: [makeClip({ asset_id: "a2", order: 0 })] },
|
||||
{ variant_index: 0, plan_id: "plan-0", clips: [makeClip({ asset_id: "a1", order: 0 })] },
|
||||
],
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
useBatchVariantPlans({
|
||||
enabled: true,
|
||||
count: 2,
|
||||
templateId: "tpl-1",
|
||||
assetIds: ["a1", "a2"],
|
||||
}),
|
||||
)
|
||||
await waitFor(() => expect(result.current.ready).toBe(true))
|
||||
expect(result.current.planIdsByVariant).toEqual(["plan-0", "plan-1"])
|
||||
})
|
||||
|
||||
it("非 ready 状态的 clip 被过滤", async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
total: 2,
|
||||
items: [
|
||||
{
|
||||
variant_index: 0,
|
||||
plan_id: "plan-0",
|
||||
clips: [
|
||||
makeClip({ asset_id: "a1", order: 0 }),
|
||||
makeClip({ asset_id: "a2", order: 1, status: "pending" }),
|
||||
],
|
||||
},
|
||||
{ variant_index: 1, plan_id: "plan-1", clips: [makeClip({ asset_id: "a3", order: 0 })] },
|
||||
],
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
useBatchVariantPlans({ enabled: true, count: 2, templateId: "tpl-1", assetIds: ["a1"] }),
|
||||
)
|
||||
await waitFor(() => expect(result.current.ready).toBe(true))
|
||||
expect(result.current.clipsByVariant[0]).toHaveLength(1)
|
||||
expect(result.current.clipsByVariant[0]?.[0]?.asset_id).toBe("a1")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest"
|
||||
import { lazyRoute } from "@/router/lazyRoute"
|
||||
|
||||
const chunkErr = () => new TypeError("Failed to fetch dynamically imported module: /assets/x.js")
|
||||
|
||||
/** fake 模块 */
|
||||
const Comp = function Comp() {}
|
||||
const factoryOk = vi.fn(async () => ({ default: Comp }))
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("lazyRoute", () => {
|
||||
it("首次成功直接返回 Component", async () => {
|
||||
const result = await lazyRoute(factoryOk)()
|
||||
expect(result).toEqual({ Component: Comp })
|
||||
expect(factoryOk).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("chunk 失败重试:前两次失败、第三次成功 → 不抛出", async () => {
|
||||
const f = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(chunkErr())
|
||||
.mockRejectedValueOnce(chunkErr())
|
||||
.mockResolvedValueOnce({ default: Comp })
|
||||
|
||||
const result = await lazyRoute(f as never)()
|
||||
expect(result).toEqual({ Component: Comp })
|
||||
expect(f).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it("chunk 失败重试 2 次仍失败 → 抛出", async () => {
|
||||
const f = vi.fn().mockRejectedValue(chunkErr())
|
||||
await expect(lazyRoute(f as never)()).rejects.toThrow(/dynamically imported/)
|
||||
expect(f).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it("非 chunk 错误立即抛出,不重试", async () => {
|
||||
const f = vi.fn().mockRejectedValue(new Error("业务模块内部报错"))
|
||||
await expect(lazyRoute(f as never)()).rejects.toThrow("业务模块内部报错")
|
||||
expect(f).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
|
||||
import {
|
||||
getChunkReloadedAt,
|
||||
goHomeRecover,
|
||||
isChunkLoadError,
|
||||
reloadForChunkError,
|
||||
} from "@/utils/chunkLoadError"
|
||||
|
||||
describe("isChunkLoadError", () => {
|
||||
it("识别 Vite 动态 import 失败", () => {
|
||||
const err = new TypeError(
|
||||
"Failed to fetch dynamically imported module: https://x/assets/AssetLibrary-abc.js",
|
||||
)
|
||||
expect(isChunkLoadError(err)).toBe(true)
|
||||
})
|
||||
|
||||
it("识别 Webpack 风格 ChunkLoadError", () => {
|
||||
const err = new Error("Loading chunk 12 failed.")
|
||||
err.name = "ChunkLoadError"
|
||||
expect(isChunkLoadError(err)).toBe(true)
|
||||
})
|
||||
|
||||
it("识别字符串形式错误", () => {
|
||||
expect(isChunkLoadError("Error loading dynamically imported module")).toBe(true)
|
||||
})
|
||||
|
||||
it("普通错误不命中", () => {
|
||||
expect(isChunkLoadError(new Error("Cannot read properties of undefined"))).toBe(false)
|
||||
expect(isChunkLoadError(null)).toBe(false)
|
||||
expect(isChunkLoadError(undefined)).toBe(false)
|
||||
expect(isChunkLoadError({ status: 500 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("reload 标记", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
// jsdom 未实现真实导航,reload 仅打 "not implemented" 警告,静默掉
|
||||
vi.spyOn(console, "error").mockImplementation(() => {})
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it("无标记返回 null", () => {
|
||||
expect(getChunkReloadedAt()).toBeNull()
|
||||
})
|
||||
|
||||
it("reloadForChunkError 写入刷新标记", () => {
|
||||
expect(() => reloadForChunkError()).not.toThrow()
|
||||
expect(getChunkReloadedAt()).not.toBeNull()
|
||||
})
|
||||
|
||||
it("标记过期(>10min)返回 null", () => {
|
||||
sessionStorage.setItem("chunk_error_reloaded_at", String(Date.now() - 11 * 60 * 1000))
|
||||
expect(getChunkReloadedAt()).toBeNull()
|
||||
})
|
||||
|
||||
it("goHomeRecover 清掉标记", () => {
|
||||
reloadForChunkError()
|
||||
expect(getChunkReloadedAt()).not.toBeNull()
|
||||
expect(() => goHomeRecover()).not.toThrow()
|
||||
expect(sessionStorage.getItem("chunk_error_reloaded_at")).toBeNull()
|
||||
})
|
||||
|
||||
it("sessionStorage 抛异常(无痕模式)时降级不崩溃", () => {
|
||||
const spy = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("Storage disabled")
|
||||
})
|
||||
const setSpy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("Storage disabled")
|
||||
})
|
||||
expect(getChunkReloadedAt()).toBeNull()
|
||||
expect(() => reloadForChunkError()).not.toThrow()
|
||||
expect(() => goHomeRecover()).not.toThrow()
|
||||
spy.mockRestore()
|
||||
setSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 发版后旧标签页懒加载 chunk 失效(白屏)的识别与恢复工具。
|
||||
*
|
||||
* 背景:页面 React Router 的 lazy 动态 import,发版后旧 chunk 文件名被删除,
|
||||
* 停留在旧标签页的用户点菜单时 import 404,抛出
|
||||
* "Failed to fetch dynamically imported module"(Vite)/ ChunkLoadError,
|
||||
* 不捕获就是整页白屏。
|
||||
*/
|
||||
|
||||
/** sessionStorage 标记:最近已经为 chunk 失效自动刷新过一次(带时间戳,10min 有效) */
|
||||
const RELOAD_FLAG_KEY = "chunk_error_reloaded_at"
|
||||
/** 标记有效期:超过后允许再次自动刷新,避免用户手动正常刷新后标记永久残留 */
|
||||
const RELOAD_FLAG_TTL_MS = 10 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Storage 在 Safari 无痕模式 / 禁用 Cookie 的浏览器 / 严格 iframe 策略下
|
||||
* 访问可能抛异常;此处统一容错,拿不到存储就降级为"无标记",绝不能让
|
||||
* 错误边界本身因读存储而崩溃。
|
||||
*/
|
||||
const safeStorage = {
|
||||
getItem: (key: string): string | null => {
|
||||
try {
|
||||
return sessionStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
setItem: (key: string, value: string): void => {
|
||||
try {
|
||||
sessionStorage.setItem(key, value)
|
||||
} catch {
|
||||
/* 存储不可用时静默降级:仅丢失"已刷新"标记,不影响恢复动作 */
|
||||
}
|
||||
},
|
||||
removeItem: (key: string): void => {
|
||||
try {
|
||||
sessionStorage.removeItem(key)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/** 判断错误是否为懒加载 chunk 加载失败(发版 404 / 网络中断 / 动态 import 失败) */
|
||||
export const isChunkLoadError = (error: unknown): boolean => {
|
||||
if (!error) return false
|
||||
// Vite: Failed to fetch dynamically imported module: /assets/xxx-yyy.js
|
||||
// Webpack: ChunkLoadError: Loading chunk xxx failed.
|
||||
const needle =
|
||||
error instanceof Error
|
||||
? `${error.name} ${error.message}`
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: ""
|
||||
return /failed to fetch dynamically imported module|chunkloaderror|loading chunk \d+ failed|error loading dynamically imported module|importing a module script failed/i.test(
|
||||
needle,
|
||||
)
|
||||
}
|
||||
|
||||
/** 读取上次自动刷新时间戳;过期或不存在返回 null */
|
||||
export const getChunkReloadedAt = (): number | null => {
|
||||
const raw = safeStorage.getItem(RELOAD_FLAG_KEY)
|
||||
if (!raw) return null
|
||||
const ts = Number(raw)
|
||||
if (!Number.isFinite(ts)) return null
|
||||
if (Date.now() - ts > RELOAD_FLAG_TTL_MS) return null
|
||||
return ts
|
||||
}
|
||||
|
||||
/** 标记"已为 chunk 失效自动刷新过",然后刷新页面 */
|
||||
export const reloadForChunkError = (): void => {
|
||||
safeStorage.setItem(RELOAD_FLAG_KEY, String(Date.now()))
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
/**
|
||||
* 硬恢复:清掉标记后回到首页(整页导航,不是当前 URL 刷新)。
|
||||
* - chunk 失效兜底:回到首页会拉取最新 index.html,彻底脱离旧 chunk 引用
|
||||
* - 非 chunk 的页面级崩溃:跳首页能绕开当前报错路由,避免"刷新-再崩"死循环
|
||||
*/
|
||||
export const goHomeRecover = (): void => {
|
||||
safeStorage.removeItem(RELOAD_FLAG_KEY)
|
||||
window.location.href = "/"
|
||||
}
|
||||
@@ -34,7 +34,9 @@ def create_video_record_and_dedup(
|
||||
fps: float = 25.0,
|
||||
name: str = "",
|
||||
thumbnail_url: str = "",
|
||||
) -> int:
|
||||
) -> dict:
|
||||
"""Returns: {"video_count": int, "is_duplicate": bool, "batch_similarity": float|None,
|
||||
"duplicate_of": str|None} —— batch_similarity 为批次内最高相似度(无批次查重时 None)。"""
|
||||
"""创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。
|
||||
|
||||
采用两阶段持久化:先计算所有指纹/查重数据(内存),
|
||||
@@ -76,6 +78,7 @@ def create_video_record_and_dedup(
|
||||
# ── Phase 2: 计算指纹 & 查重(全部在内存) ────────────────
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = None
|
||||
batch_similarity: float | None = None
|
||||
|
||||
try:
|
||||
fingerprint = deduplicator.compute_fingerprint(video_path)
|
||||
@@ -105,9 +108,11 @@ def create_video_record_and_dedup(
|
||||
)
|
||||
|
||||
# (b) 批次内查重(仅当有 batch_id 时)
|
||||
batch_similarity: float | None = None
|
||||
if not duplicate_result and batch_id:
|
||||
duplicate_result = deduplicator.check_batch_duplicate(fingerprint, batch_id, video_id, session)
|
||||
|
||||
if duplicate_result:
|
||||
batch_similarity = float(duplicate_result.get("similarity", 0.0))
|
||||
if duplicate_result:
|
||||
generated_video.is_duplicate = True
|
||||
generated_video.duplicate_of = duplicate_result["duplicate_of"]
|
||||
@@ -161,7 +166,12 @@ def create_video_record_and_dedup(
|
||||
generated_video.is_duplicate,
|
||||
generated_video.duplicate_rate,
|
||||
)
|
||||
return 1
|
||||
return {
|
||||
"video_count": 1,
|
||||
"is_duplicate": bool(generated_video.is_duplicate),
|
||||
"batch_similarity": batch_similarity,
|
||||
"duplicate_of": generated_video.duplicate_of,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to create video record / dedup for task %s: %s",
|
||||
@@ -169,4 +179,4 @@ def create_video_record_and_dedup(
|
||||
e,
|
||||
)
|
||||
session.rollback()
|
||||
return 0
|
||||
return {"video_count": 0, "is_duplicate": False, "batch_similarity": None, "duplicate_of": None}
|
||||
|
||||
@@ -17,6 +17,12 @@ apply_queue_settings(celery_app)
|
||||
# 长渲染任务预取 1,避免任务被预取占住导致调度不均
|
||||
celery_app.conf.worker_prefetch_multiplier = GENERATION_WORKER_PREFETCH_MULTIPLIER
|
||||
celery_app.conf.task_acks_late = True # worker 崩溃时未完成任务重回队列,由执行前守卫丢弃作废消息
|
||||
# worker 进程被 OOM/容器硬杀时拒绝 ack,消息留在队列由其他 worker 接手
|
||||
celery_app.conf.task_reject_on_worker_lost = True
|
||||
# Redis broker 消息可见性超时(#1714):acks_late 下,消息被预取后 visibility_timeout
|
||||
# 内未 ack 才会重投。长任务(ingest HEVC 转码 20-30 分钟、生成硬超时 11 分钟)
|
||||
# 必须远大于最长执行时间,否则正常任务会在执行中被误重投;4 小时覆盖最长转码 + 余量。
|
||||
celery_app.conf.broker_transport_options = {"visibility_timeout": 4 * 60 * 60}
|
||||
|
||||
celery_app.conf.imports = (
|
||||
"worker_app.tasks.health",
|
||||
@@ -48,4 +54,11 @@ celery_app.conf.beat_schedule = {
|
||||
"schedule": 300.0, # 每 5 分钟(秒)
|
||||
"options": {"expires": 240},
|
||||
},
|
||||
# 上传/转码链路孤儿巡检:worker 重启丢 prefetch 消息后,卡 pending/processing
|
||||
# 的 ingest_job + asset 占位超时标终态(#1714)。转码任务较长,10 分钟一轮
|
||||
"cleanup-stale-ingest-jobs": {
|
||||
"task": "worker.cleanup_stale_ingest_jobs",
|
||||
"schedule": 600.0, # 每 10 分钟(秒)
|
||||
"options": {"expires": 540},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def mark_asset_used_for_generation(asset) -> None:
|
||||
def mark_asset_used_for_generation(asset, times: int = 1) -> None:
|
||||
"""标记素材在成片中被使用,累加使用次数。
|
||||
|
||||
Args:
|
||||
asset: Asset 实体(metadata 就地更新)
|
||||
times: 本次成片实际使用次数(= 最终成片 plan 中引用该素材的片段数)。
|
||||
按「成片实际渲染的片段」计数而非请求传入的 asset_ids 列表——
|
||||
请求列表可能含未被 plan 选用的素材(不应计数),同一素材在多片段
|
||||
复用时应按片段数累加(高频排除/未使用偏好才与真实渲染强度挂钩)。
|
||||
"""
|
||||
times = max(1, int(times or 1))
|
||||
asset.metadata = {
|
||||
**asset.metadata,
|
||||
"generation_use_count": int(asset.metadata.get("generation_use_count") or 0) + 1,
|
||||
"generation_use_count": int(asset.metadata.get("generation_use_count") or 0) + times,
|
||||
"last_used_at": datetime.now(timezone.utc).isoformat(),
|
||||
"review_status": asset.metadata.get("review_status") or "pending_review",
|
||||
}
|
||||
|
||||
@@ -257,3 +257,32 @@ def _on_worker_ready(sender, **kwargs): # pragma: no cover
|
||||
result = cleanup_all_stale_tasks()
|
||||
total = result["generation_tasks"] + result["jobs"]
|
||||
logger.info("Worker 启动清理完成,共清理 %d 个孤儿任务", total)
|
||||
|
||||
|
||||
@worker_ready.connect
|
||||
def _recover_stuck_ingest_jobs_on_ready(sender, **kwargs): # pragma: no cover
|
||||
"""Worker 启动完成后恢复卡死在 processing 的 ingest_job(#1714)。
|
||||
|
||||
容器重启/进程 OOM 导致 transcode 队列 unacked 消息未重投时,processing
|
||||
ingest_job 会永久卡死。启动时扫描 processing 超 10 分钟的 job,CAS 重置
|
||||
pending 并重新派单;Redis 锁保证同容器 generation/transcode 双 worker
|
||||
只有一个执行恢复。旧消息若后来重投,ingest_asset 执行前守卫会丢弃。
|
||||
"""
|
||||
try:
|
||||
from packages.application.ingest_orphan_cleanup import (
|
||||
make_redis_recovery_lock,
|
||||
recover_stuck_ingest_jobs_on_startup,
|
||||
)
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
recovered = recover_stuck_ingest_jobs_on_startup(
|
||||
session,
|
||||
lock_acquire=make_redis_recovery_lock(),
|
||||
stuck_minutes=10,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
logger.info("Worker 启动 ingest 恢复完成,共重新派单 %d 个卡死任务", recovered)
|
||||
except Exception as e: # noqa: BLE001 — 启动恢复失败不能阻断 worker 起服
|
||||
logger.error("启动 ingest 恢复扫描失败(beat 巡检仍会兜底标 failed): %s", e, exc_info=True)
|
||||
|
||||
@@ -16,6 +16,12 @@ from worker_app.tasks._startup import (
|
||||
cleanup_stale_pending_tasks,
|
||||
)
|
||||
|
||||
from packages.application.ingest_orphan_cleanup import (
|
||||
ASSET_ORPHAN_TIMEOUT_MINUTES,
|
||||
INGEST_PENDING_TIMEOUT_MINUTES,
|
||||
INGEST_PROCESSING_TIMEOUT_MINUTES,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -69,3 +75,53 @@ def scheduled_cleanup_stale_running(timeout_minutes: int = ORPHAN_TASK_TIMEOUT_M
|
||||
timeout_minutes,
|
||||
)
|
||||
return {"generation_tasks": gen_count, "jobs": job_count}
|
||||
|
||||
|
||||
@shared_task(name="worker.cleanup_stale_ingest_jobs")
|
||||
def scheduled_cleanup_stale_ingest_jobs(
|
||||
processing_timeout_minutes: int = INGEST_PROCESSING_TIMEOUT_MINUTES,
|
||||
pending_timeout_minutes: int = INGEST_PENDING_TIMEOUT_MINUTES,
|
||||
orphan_asset_timeout_minutes: int = ASSET_ORPHAN_TIMEOUT_MINUTES,
|
||||
) -> dict:
|
||||
"""Celery Beat 调度:清理上传/转码链路(IngestJob + Asset)孤儿记录。
|
||||
|
||||
每 10 分钟执行一次。worker 容器重启/进程 OOM 时,已 prefetch 的 transcode
|
||||
celery 消息会丢失(队列里也不存在),ingest_job 永久卡 pending/processing、
|
||||
asset 永久卡 processing/uploading,没有兜底永远不会恢复(#1714)。
|
||||
|
||||
- ingest_job processing > processing_timeout_minutes / pending > pending_timeout_minutes
|
||||
→ 标 failed;关联 asset 占位(processing/uploading)联动标 error
|
||||
- 无 ingest_job 关联、created_at > orphan_asset_timeout_minutes 的占位 asset
|
||||
→ 标 error
|
||||
- 作废 celery 消息 revoke + 物理清除(防重投,执行前守卫是第二道防线)
|
||||
"""
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
from packages.application.ingest_orphan_cleanup import (
|
||||
cleanup_orphan_processing_assets,
|
||||
cleanup_stale_ingest_jobs,
|
||||
revoke_stale_ingest_messages,
|
||||
)
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
job_items, asset_ids = cleanup_stale_ingest_jobs(
|
||||
session,
|
||||
processing_timeout_minutes=processing_timeout_minutes,
|
||||
pending_timeout_minutes=pending_timeout_minutes,
|
||||
)
|
||||
orphan_asset_ids = cleanup_orphan_processing_assets(session, timeout_minutes=orphan_asset_timeout_minutes)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
purged = revoke_stale_ingest_messages(job_items) if job_items else 0
|
||||
total_jobs = len(job_items)
|
||||
total_assets = len(set(asset_ids) | set(orphan_asset_ids))
|
||||
if total_jobs or total_assets:
|
||||
logger.warning(
|
||||
"[Beat] 清理 ingest 链路孤儿: stale_jobs=%d, assets→error=%d, 队列清除消息=%d",
|
||||
total_jobs,
|
||||
total_assets,
|
||||
purged,
|
||||
)
|
||||
return {"stale_jobs": total_jobs, "assets_to_error": total_assets, "purged_messages": purged}
|
||||
|
||||
@@ -383,35 +383,131 @@ def _load_task_info(task_id: str) -> dict | None:
|
||||
session.close()
|
||||
|
||||
|
||||
def _upload_and_record(
|
||||
# ── #1743 批量变体重渲/封面判定(纯函数,便于单测) ──────────────────────
|
||||
BATCH_RENDER_SIMILARITY_LIMIT = 0.20
|
||||
"""批次内成片查重相似度阈值:超过则重选独立 plan 重渲一次(20%)。"""
|
||||
|
||||
|
||||
def should_rerender_for_batch_dedup(*, batch_id: str, render_attempt: int, batch_similarity) -> bool:
|
||||
"""批次内查重后判定是否需要重选 plan 重渲。
|
||||
|
||||
条件(全部满足才重渲):批次任务、首版(attempt==0)、查重率已得出、相似度 > 20%。
|
||||
非批次任务 / 已是重渲版 / 查重率缺失 / 相似度达标 → 不重渲。
|
||||
"""
|
||||
if not batch_id:
|
||||
return False
|
||||
if render_attempt >= 1:
|
||||
return False
|
||||
if batch_similarity is None:
|
||||
return False
|
||||
return float(batch_similarity) > BATCH_RENDER_SIMILARITY_LIMIT
|
||||
|
||||
|
||||
def pick_batch_cover_index(task_id: str, candidate_count: int) -> int:
|
||||
"""批次变体封面帧选取:按 task_id md5 稳定哈希分散到候选帧。
|
||||
|
||||
同任务重试结果稳定;批次内不同 task_id 哈希后分散,避免 N 个变体都抽 frame_0
|
||||
导致封面雷同。非批次调用方应直接取 0(主流程按 batch_id 区分)。
|
||||
"""
|
||||
if candidate_count <= 1:
|
||||
return 0
|
||||
import hashlib
|
||||
|
||||
return int(hashlib.md5(task_id.encode()).hexdigest(), 16) % candidate_count
|
||||
|
||||
|
||||
def _count_plan_clip_asset_usage(session, plan_id: str) -> dict[str, int]:
|
||||
"""统计最终成片 plan 中每个素材被片段引用的次数。
|
||||
|
||||
计数口径(#1743):以成片实际渲染的 edit_plan_clips 为准——
|
||||
同一素材在多个片段复用按片段数累加;未被 plan 选用的素材(即使
|
||||
出现在请求 asset_ids 中)不计数。
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanClipModel
|
||||
|
||||
rows = session.query(EditPlanClipModel.asset_id).filter(EditPlanClipModel.plan_id == plan_id).all()
|
||||
counts: dict[str, int] = {}
|
||||
for (asset_id,) in rows:
|
||||
if asset_id:
|
||||
counts[asset_id] = counts.get(asset_id, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def _record_rendered_asset_usage(
|
||||
session,
|
||||
plan_id: str,
|
||||
task_id: str,
|
||||
fallback_asset_ids: list[str] | None = None,
|
||||
) -> int:
|
||||
"""按最终成片 plan 的实际片段统计素材使用次数并回写 metadata。
|
||||
|
||||
plan 无有效片段素材(异常数据)时退回 fallback_asset_ids 每个计 1 次,
|
||||
保证使用统计不因数据异常完全丢失。单素材回写失败不影响其他素材。
|
||||
|
||||
Returns: 实际回写次数的素材数量。
|
||||
"""
|
||||
from worker_app.core.asset_usage import mark_asset_used_for_generation
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import (
|
||||
SQLAlchemyAssetRepository,
|
||||
)
|
||||
|
||||
used_counts = _count_plan_clip_asset_usage(session, plan_id)
|
||||
if not used_counts and fallback_asset_ids:
|
||||
used_counts = {aid: 1 for aid in fallback_asset_ids if aid}
|
||||
if not used_counts:
|
||||
logger.info("[task_id=%s] 素材使用计数: plan=%s 无有效片段素材,跳过", task_id, plan_id)
|
||||
return 0
|
||||
|
||||
asset_repo = SQLAlchemyAssetRepository(session)
|
||||
written = 0
|
||||
for aid, times in used_counts.items():
|
||||
try:
|
||||
asset = asset_repo.get(aid)
|
||||
if asset:
|
||||
mark_asset_used_for_generation(asset, times=times)
|
||||
asset_repo.update(asset)
|
||||
written += 1
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[task_id=%s] 更新素材使用次数失败: asset_id=%s times=%d",
|
||||
task_id,
|
||||
aid,
|
||||
times,
|
||||
exc_info=True,
|
||||
)
|
||||
logger.info(
|
||||
"[task_id=%s] 素材使用计数回写完成(plan=%s): %d 个素材, 片段引用 %d 次",
|
||||
task_id,
|
||||
plan_id,
|
||||
written,
|
||||
sum(used_counts.values()),
|
||||
)
|
||||
return written
|
||||
|
||||
|
||||
def _upload_rendered_video(
|
||||
task_id: str,
|
||||
output_path: Path,
|
||||
project_id: str,
|
||||
batch_id: str,
|
||||
editing_mode,
|
||||
user_id: str = "",
|
||||
video_name: str = "",
|
||||
thumbnail_url: str = "",
|
||||
) -> tuple[str, float, int, int]:
|
||||
"""上传 OSS、创建视频记录并查重。
|
||||
*,
|
||||
attempt: int = 0,
|
||||
) -> tuple[str, str]:
|
||||
"""上传成片到 OSS(不落库)。attempt>0 时文件名带轮次后缀,避免覆盖首版。
|
||||
|
||||
Returns:
|
||||
(file_url, duration, file_size, video_count)
|
||||
Returns: (file_url, storage_key)
|
||||
"""
|
||||
# project_id 可能为空(模板编辑器草稿不属于任何项目),过滤空段避免 OSS key 出现 //
|
||||
path_parts = [p for p in ("generated", "projects", project_id, "tasks", task_id, output_path.name) if p]
|
||||
suffix = f"_v{attempt}" if attempt > 0 else ""
|
||||
stem = output_path.stem
|
||||
name = f"{stem}{suffix}{output_path.suffix or '.mp4'}"
|
||||
path_parts = [p for p in ("generated", "projects", project_id, "tasks", task_id, name) if p]
|
||||
storage_key = "/".join(path_parts)
|
||||
file_size = output_path.stat().st_size
|
||||
|
||||
# 上传 OSS
|
||||
logger.info("[task_id=%s] [OSS上传] 开始上传: size=%d", task_id, file_size)
|
||||
upload_start = time.monotonic()
|
||||
logger.info("[task_id=%s] [OSS上传] 开始上传(attempt=%d): size=%d", task_id, attempt, output_path.stat().st_size)
|
||||
file_url = upload_to_oss(output_path, storage_key)
|
||||
upload_elapsed = time.monotonic() - upload_start
|
||||
if not file_url:
|
||||
raise RuntimeError(f"OSS 上传失败: task_id={task_id}, storage_key={storage_key}")
|
||||
|
||||
# 校验 URL 可达性(P0-2: 私有 bucket 用预签名 + object_exists 降级)
|
||||
verify_url = get_signed_download_url(file_url, expires_seconds=300) or file_url
|
||||
if not _verify_url_accessible(verify_url):
|
||||
from video_processing.oss_helpers import normalize_storage_key, oss_bucket
|
||||
@@ -420,25 +516,58 @@ def _upload_and_record(
|
||||
key = normalize_storage_key(file_url)
|
||||
if not (bucket and bucket.object_exists(key)):
|
||||
raise RuntimeError(
|
||||
f"OSS 上传后 URL 不可访问且 object_exists 失败: file_url={file_url}, " f"storage_key={storage_key}"
|
||||
f"OSS 上传后 URL 不可访问且 object_exists 失败: file_url={file_url}, storage_key={storage_key}"
|
||||
)
|
||||
logger.info(
|
||||
"URL 校验失败但 object_exists 确认文件存在,视为上传成功: storage_key=%s",
|
||||
key,
|
||||
)
|
||||
logger.info("URL 校验失败但 object_exists 确认文件存在,视为上传成功: storage_key=%s", key)
|
||||
return file_url, storage_key
|
||||
|
||||
logger.info(
|
||||
"[task_id=%s] [OSS上传] 成功: 耗时=%.1fs, file_url=%s",
|
||||
task_id,
|
||||
upload_elapsed,
|
||||
file_url,
|
||||
)
|
||||
|
||||
# 创建 GeneratedVideo 记录 + 查重
|
||||
duration = probe_duration(output_path)
|
||||
def _reselect_plan_for_batch_retry(task_id: str, plan_id: str, task_info: dict) -> str | None:
|
||||
"""批次内查重超阈值后,为当前任务重新独立选片生成新 plan(#1743 自动重渲)。
|
||||
|
||||
复用 API 侧同一套 EditPlanService.reselect_plan_for_variant(packages 层
|
||||
variant_plan_selector 纯核心),素材池来自任务 asset_ids + 源 plan 素材。
|
||||
成功返回新 plan_id;失败返回 None(调用方放弃重渲,保留首版)。
|
||||
"""
|
||||
try:
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
svc = EditPlanService(db)
|
||||
asset_pool = list(task_info.get("task_asset_ids") or [])
|
||||
new_plan = svc.reselect_plan_for_variant(
|
||||
plan_id,
|
||||
asset_pool,
|
||||
created_by_user_id=task_info.get("user_id", ""),
|
||||
name_suffix="重渲变体",
|
||||
)
|
||||
return new_plan.id
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
logger.warning("[task_id=%s] 批次重渲前重选 plan 失败,放弃重渲", task_id, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _record_video_and_dedup(
|
||||
*,
|
||||
task_id: str,
|
||||
project_id: str,
|
||||
batch_id: str,
|
||||
editing_mode,
|
||||
user_id: str,
|
||||
file_url: str,
|
||||
file_size: int,
|
||||
video_path: str,
|
||||
video_name: str = "",
|
||||
thumbnail_url: str = "",
|
||||
) -> dict:
|
||||
"""成片落库 + 指纹查重(含批次内)。返回查重信息 dict。"""
|
||||
duration = probe_duration(Path(video_path))
|
||||
dedup_session = SessionLocal()
|
||||
try:
|
||||
video_count = create_video_record_and_dedup(
|
||||
result = create_video_record_and_dedup(
|
||||
generation_task_id=task_id,
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
@@ -446,7 +575,7 @@ def _upload_and_record(
|
||||
file_url=file_url,
|
||||
file_size=file_size,
|
||||
duration=duration,
|
||||
video_path=str(output_path),
|
||||
video_path=video_path,
|
||||
mode=editing_mode.value,
|
||||
session=dedup_session,
|
||||
name=video_name,
|
||||
@@ -454,11 +583,12 @@ def _upload_and_record(
|
||||
)
|
||||
finally:
|
||||
dedup_session.close()
|
||||
|
||||
return file_url, duration, file_size, video_count or 1
|
||||
result["duration"] = duration
|
||||
return result
|
||||
|
||||
|
||||
# ── Celery Task ──────────────────────────────────────────────────────────────
|
||||
# ── Celery Task ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _sync_task_config_to_plan(source_edit_plan_id: str, task_info: dict, db) -> str | None:
|
||||
@@ -729,19 +859,29 @@ def generate_video(self, task_id: str) -> dict:
|
||||
gen_task.append_log("渲染模式", "从草稿数据渲染(与预览一致)")
|
||||
_flush_logs(task_id, gen_task)
|
||||
|
||||
output_path, render_duration, cover_candidates, voiceover_tmp_path, render_temp_dir, thumbnail_url = (
|
||||
_render_from_edit_plan(
|
||||
# ── 渲染→上传→查重→(批次超阈值则重选 plan 重渲一次)循环(#1743)──
|
||||
current_plan_id = source_edit_plan_id
|
||||
file_url = ""
|
||||
duration = 0.0
|
||||
file_size = 0
|
||||
video_count = 1
|
||||
file_size_final = 0
|
||||
for render_attempt in range(2): # 首版 + 最多 1 次重渲
|
||||
(
|
||||
output_path,
|
||||
render_duration,
|
||||
cover_candidates,
|
||||
voiceover_tmp_path,
|
||||
render_temp_dir,
|
||||
thumbnail_url,
|
||||
) = _render_from_edit_plan(
|
||||
task_id=task_id,
|
||||
source_edit_plan_id=source_edit_plan_id,
|
||||
source_edit_plan_id=current_plan_id,
|
||||
task_info=task_info,
|
||||
)
|
||||
)
|
||||
# 从这里开始,render_temp_dir 已赋值,必须确保异常时也能清理
|
||||
try:
|
||||
if gen_task:
|
||||
gen_task.append_log("渲染", f"渲染完成, 时长={render_duration:.1f}s")
|
||||
gen_task.append_log("渲染", f"渲染完成(第{render_attempt + 1}版), 时长={render_duration:.1f}s")
|
||||
_flush_logs(task_id, gen_task)
|
||||
|
||||
_update_task_progress(task_id, 80, "渲染完成")
|
||||
|
||||
# ── 3.5 随机边缘裁剪降重(#1664) ──────────────────────────
|
||||
@@ -751,7 +891,7 @@ def generate_video(self, task_id: str) -> dict:
|
||||
cropped_path = random_edge_crop(output_path)
|
||||
if cropped_path != output_path:
|
||||
output_path = cropped_path
|
||||
if gen_task:
|
||||
if gen_task and render_attempt == 0:
|
||||
gen_task.append_log("边缘裁剪", "已应用随机 2-5% 边缘裁剪降重")
|
||||
_flush_logs(task_id, gen_task)
|
||||
logger.info("[task_id=%s] 随机边缘裁剪完成: %s", task_id, output_path)
|
||||
@@ -762,45 +902,110 @@ def generate_video(self, task_id: str) -> dict:
|
||||
crop_err,
|
||||
exc_info=True,
|
||||
)
|
||||
if gen_task:
|
||||
gen_task.append_log("边缘裁剪", f"裁剪失败,使用原始视频: {crop_err}")
|
||||
_flush_logs(task_id, gen_task)
|
||||
|
||||
# ── 4. 上传 OSS + 查重记录 ───────────────────────────────
|
||||
# ── 4. 上传 OSS(不落库) ───────────────────────────────
|
||||
_update_task_progress(task_id, 85, "开始上传")
|
||||
file_url, duration, file_size, video_count = _upload_and_record(
|
||||
file_url, _storage_key = _upload_rendered_video(
|
||||
task_id=task_id,
|
||||
output_path=output_path,
|
||||
project_id=project_id,
|
||||
attempt=render_attempt,
|
||||
)
|
||||
file_size = output_path.stat().st_size
|
||||
|
||||
# ── 4.5 落库 + 查重(批次任务检查批次内相似度) ───────────
|
||||
dedup_info = _record_video_and_dedup(
|
||||
task_id=task_id,
|
||||
project_id=project_id,
|
||||
batch_id=batch_id,
|
||||
editing_mode=editing_mode,
|
||||
user_id=user_id,
|
||||
file_url=file_url,
|
||||
file_size=file_size,
|
||||
video_path=str(output_path),
|
||||
video_name=task_info.get("video_title", ""),
|
||||
thumbnail_url=thumbnail_url,
|
||||
)
|
||||
duration = dedup_info.get("duration", render_duration)
|
||||
video_count = dedup_info.get("video_count", 1)
|
||||
batch_sim = dedup_info.get("batch_similarity")
|
||||
|
||||
if gen_task:
|
||||
gen_task.append_log(
|
||||
"OSS上传",
|
||||
f"上传成功, 大小={file_size}",
|
||||
f"第{render_attempt + 1}版上传成功, 大小={file_size}"
|
||||
+ (f", 批次相似度={batch_sim:.0%}" if batch_sim is not None else ""),
|
||||
file_size=file_size,
|
||||
file_url=file_url,
|
||||
)
|
||||
_flush_logs(task_id, gen_task)
|
||||
|
||||
_update_task_progress(task_id, 95, "上传完成")
|
||||
finally:
|
||||
# 清理渲染临时目录(无论后续步骤成功与否都清理)
|
||||
# 非批次 / 相似度达标 / 已是最后一次 → 结束循环
|
||||
if not should_rerender_for_batch_dedup(
|
||||
batch_id=batch_id,
|
||||
render_attempt=render_attempt,
|
||||
batch_similarity=batch_sim,
|
||||
):
|
||||
file_size_final = file_size
|
||||
break
|
||||
|
||||
# 批次内相似度过高:重选独立 plan 后重渲一次
|
||||
logger.warning(
|
||||
"[task_id=%s] 批次内查重相似度 %.2f 超阈值 %.2f,重选 plan 重渲",
|
||||
task_id,
|
||||
batch_sim,
|
||||
BATCH_RENDER_SIMILARITY_LIMIT,
|
||||
)
|
||||
if gen_task:
|
||||
gen_task.append_log("批次查重", f"与批次内成片相似度过高({batch_sim:.0%}),重新选片渲染")
|
||||
_flush_logs(task_id, gen_task)
|
||||
new_plan_id = _reselect_plan_for_batch_retry(task_id, current_plan_id, task_info)
|
||||
if not new_plan_id:
|
||||
logger.warning("[task_id=%s] 重选 plan 失败,保留首版", task_id)
|
||||
file_size_final = file_size
|
||||
break
|
||||
# 回写任务关联的 plan(重渲版以新 plan 渲染)
|
||||
try:
|
||||
_ps = SessionLocal()
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
|
||||
_pr = SQLAlchemyGenerationTaskRepository(_ps)
|
||||
_gt = _pr.get(task_id)
|
||||
if _gt:
|
||||
_gt.source_edit_plan_id = new_plan_id
|
||||
_pr.update(_gt)
|
||||
finally:
|
||||
_ps.close()
|
||||
except Exception:
|
||||
logger.warning("[task_id=%s] 回写重渲 plan_id 失败", task_id, exc_info=True)
|
||||
current_plan_id = new_plan_id
|
||||
# 清理本轮临时目录,下一轮重新渲染
|
||||
if render_temp_dir:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(render_temp_dir, ignore_errors=True)
|
||||
logger.info("[task_id=%s] 渲染临时目录已清理: %s", task_id, render_temp_dir)
|
||||
render_temp_dir = None
|
||||
|
||||
file_size = file_size_final or file_size
|
||||
_update_task_progress(task_id, 95, "上传完成")
|
||||
|
||||
# 渲染结束后清理临时目录(重渲循环内每轮已清理,此处兜底最后一轮)
|
||||
if render_temp_dir:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(render_temp_dir, ignore_errors=True)
|
||||
logger.info("[task_id=%s] 渲染临时目录已清理: %s", task_id, render_temp_dir)
|
||||
|
||||
# ── 4.5 封面帧持久化 ────────────────────────────────────────────
|
||||
try:
|
||||
if cover_candidates:
|
||||
first = cover_candidates[0]
|
||||
# #1743:批量变体封面差异化——候选帧按 task_id 稳定哈希分散选取
|
||||
# (同任务重试稳定,批次内不同任务落在不同帧位),非批次取首帧。
|
||||
_cover_idx = pick_batch_cover_index(task_id, len(cover_candidates)) if batch_id else 0
|
||||
first = cover_candidates[_cover_idx]
|
||||
cover_frame_url = first.get("image_url") or first.get("url") or ""
|
||||
if cover_frame_url:
|
||||
_cover_session = SessionLocal()
|
||||
@@ -859,35 +1064,22 @@ def generate_video(self, task_id: str) -> dict:
|
||||
logger.warning("[task_id=%s] 更新标题使用次数异常", task_id, exc_info=True)
|
||||
|
||||
# 5.2 更新素材使用次数
|
||||
# 按「最终成片 plan 实际渲染的片段」计数(#1743):不用请求传入的
|
||||
# task.asset_ids(可能含未被 plan 选用的素材),同一素材多片段复用
|
||||
# 按片段数累加,使 unused_bonus / 高频排除与真实渲染强度挂钩。
|
||||
# current_plan_id 是重渲循环结束后最终成片所用 plan(首版或重渲版)。
|
||||
try:
|
||||
from worker_app.core.asset_usage import mark_asset_used_for_generation
|
||||
|
||||
_asset_session = SessionLocal()
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import (
|
||||
SQLAlchemyAssetRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||
SQLAlchemyGenerationTaskRepository,
|
||||
)
|
||||
|
||||
_task_repo = SQLAlchemyGenerationTaskRepository(_asset_session)
|
||||
_asset_repo = SQLAlchemyAssetRepository(_asset_session)
|
||||
_gen_task = _task_repo.get(task_id)
|
||||
if _gen_task and _gen_task.asset_ids:
|
||||
for _aid in _gen_task.asset_ids:
|
||||
try:
|
||||
_asset = _asset_repo.get(_aid)
|
||||
if _asset:
|
||||
mark_asset_used_for_generation(_asset)
|
||||
_asset_repo.update(_asset)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[task_id=%s] 更新素材使用次数失败: asset_id=%s",
|
||||
task_id,
|
||||
_aid,
|
||||
exc_info=True,
|
||||
)
|
||||
_fallback_ids: list[str] = []
|
||||
_gt_for_assets = SQLAlchemyGenerationTaskRepository(_asset_session).get(task_id)
|
||||
if _gt_for_assets:
|
||||
_fallback_ids = list(_gt_for_assets.asset_ids or [])
|
||||
_record_rendered_asset_usage(_asset_session, current_plan_id, task_id, _fallback_ids)
|
||||
finally:
|
||||
_asset_session.close()
|
||||
except Exception:
|
||||
|
||||
@@ -14,6 +14,10 @@ server {
|
||||
# SPA routing - index.html 禁止缓存,确保每次获取最新版本
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache,
|
||||
# 每次校验 ETag/Last-Modified,保证发版后旧标签页重新加载拿到新 chunk 引用;
|
||||
# 带 hash 的静态资源由下方 ~* \.(js|css...) location 优先匹配,不受影响、保持 immutable
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
|
||||
# API proxy — Production 环境代理到 production API 容器
|
||||
|
||||
@@ -21,6 +21,10 @@ server {
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache,
|
||||
# 每次校验 ETag/Last-Modified,保证发版后旧标签页重新加载拿到新 chunk 引用;
|
||||
# 带 hash 的静态资源由下方 ~* \.(js|css...) location 优先匹配,不受影响、保持 immutable
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
|
||||
# API proxy — Staging 环境代理到 staging API 容器
|
||||
|
||||
@@ -36,6 +36,7 @@ celery \
|
||||
worker \
|
||||
--loglevel=info \
|
||||
"-B" \
|
||||
-s /tmp/celerybeat-schedule \
|
||||
-Q generation \
|
||||
"--concurrency=${GEN_CONCURRENCY}" \
|
||||
"--max-tasks-per-child=${MAX_TASKS}" \
|
||||
|
||||
@@ -16,6 +16,10 @@ server {
|
||||
# 注意:不能加 $uri/,否则 /assets 等与构建产物目录同名的路由会被当成目录访问,返回 403
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache,
|
||||
# 每次校验 ETag/Last-Modified,保证发版后旧标签页重新加载拿到新 chunk 引用;
|
||||
# 带 hash 的静态资源由下方 ~* \.(js|css...) location 优先匹配,不受影响、保持 immutable
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
|
||||
@@ -23,6 +23,10 @@ server {
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache,
|
||||
# 每次校验 ETag/Last-Modified,保证发版后旧标签页重新加载拿到新 chunk 引用;
|
||||
# 带 hash 的静态资源由下方 ~* \.(js|css...) location 优先匹配,不受影响、保持 immutable
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
|
||||
@@ -33,6 +33,10 @@ server {
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# HTML 文档(含 try_files 回退的 SPA 路由,如 /login /app/dashboard)一律 no-cache,
|
||||
# 每次校验 ETag/Last-Modified,保证发版后旧标签页重新加载拿到新 chunk 引用;
|
||||
# 带 hash 的静态资源由下方 ~* \.(js|css...) location 优先匹配,不受影响、保持 immutable
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
|
||||
@@ -488,20 +488,25 @@ class SQLAlchemyAssetRepository:
|
||||
|
||||
用于旧客户端未传 file_hash/client_upload_id 时,防止 complete 超时重试
|
||||
反复创建 PROCESSING 占位记录。只命中"活动中"的近期记录,READY 历史素材不拦。
|
||||
|
||||
严格模式(#1714 误杀修复):file_size 必须 > 0 且与记录大小严格一致;
|
||||
file_size=0(大小未知)时直接返回 None——宁可漏判(极端情况下多建一条
|
||||
占位)也不可仅凭同名 + processing 误杀内容全新的视频。
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
if not name:
|
||||
return None
|
||||
if not file_size or file_size <= 0:
|
||||
return None
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=within_minutes)
|
||||
query = self.session.query(AssetModel).filter(
|
||||
AssetModel.asset_library_id == library_id,
|
||||
AssetModel.name == name,
|
||||
AssetModel.status.in_([AssetStatus.UPLOADING.value, AssetStatus.PROCESSING.value]),
|
||||
AssetModel.created_at >= cutoff,
|
||||
AssetModel.file_size == file_size,
|
||||
)
|
||||
if file_size and file_size > 0:
|
||||
query = query.filter(AssetModel.file_size == file_size)
|
||||
model = query.order_by(AssetModel.created_at.desc()).first()
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
@@ -38,6 +38,7 @@ class UserModel(Base):
|
||||
phone = Column(String(32), nullable=True, unique=True, index=True)
|
||||
phone_verified = Column(Boolean, nullable=False, default=False)
|
||||
binding_completed_at = Column(DateTime, nullable=True)
|
||||
profile_completed = Column(Boolean, nullable=False, default=True, server_default="true")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ class SQLAlchemyUserRepository(UserRepository):
|
||||
model.phone = user.phone
|
||||
model.phone_verified = user.phone_verified
|
||||
model.binding_completed_at = user.binding_completed_at
|
||||
model.profile_completed = user.profile_completed
|
||||
model.created_at = user.created_at
|
||||
|
||||
self.session.commit()
|
||||
@@ -113,5 +114,6 @@ class SQLAlchemyUserRepository(UserRepository):
|
||||
phone=model.phone,
|
||||
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,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
微信同步登录/注册 Use Case
|
||||
|
||||
供 BFF 层调用的系统级接口:
|
||||
- 根据 openid 查找用户,找到则登录返回 token
|
||||
- 没找到则创建新用户并返回 token
|
||||
- 优先按 unionid 识别用户(跨应用/跨端识别同一微信用户)
|
||||
- 再按 openid 识别(同一应用内)
|
||||
- openid 命中老账号但 unionid 缺失时补写 unionid(开放平台绑定前的存量账号自动关联)
|
||||
- 都未命中则创建新用户
|
||||
- 支持 unionid 跨应用关联
|
||||
"""
|
||||
|
||||
@@ -32,7 +34,7 @@ class WechatSyncRequest:
|
||||
):
|
||||
self.openid = openid.strip()
|
||||
self.unionid = unionid.strip() if unionid else ""
|
||||
self.nickname = nickname or "微信用户"
|
||||
self.nickname = nickname or "小虾同学" # 微信新规拿不到真实昵称,新用户默认昵称
|
||||
self.avatar_url = avatar_url or ""
|
||||
self.source = source
|
||||
|
||||
@@ -82,10 +84,10 @@ class WechatSyncResponse:
|
||||
|
||||
|
||||
class WechatSyncUseCase:
|
||||
"""微信同步登录/注册用例
|
||||
"""微信登录/注册同步用例
|
||||
|
||||
系统级接口,由 BFF 通过 API Key 调用。
|
||||
职责:根据 openid 查找或创建用户,返回 SaaS token。
|
||||
职责:根据 unionid/openid 查找或创建用户,返回 SaaS token。
|
||||
"""
|
||||
|
||||
def __init__(self, user_repository, session_store=None, jwt_secret_key: str | None = None):
|
||||
@@ -105,24 +107,54 @@ class WechatSyncUseCase:
|
||||
return None, "openid is required"
|
||||
|
||||
is_new_user = False
|
||||
user = None
|
||||
openid_user = None
|
||||
unionid_user = None
|
||||
|
||||
# 1. 按 openid 查找用户
|
||||
user = self.user_repository.find_by_wechat_openid(request.openid)
|
||||
# 1. 先按 unionid 查找(跨应用识别同一微信用户,优先级最高)
|
||||
if request.unionid:
|
||||
unionid_user = self.user_repository.find_by_wechat_unionid(request.unionid)
|
||||
|
||||
# 2. 如果 openid 没找到,尝试 unionid
|
||||
if not user and request.unionid:
|
||||
user = self.user_repository.find_by_wechat_unionid(request.unionid)
|
||||
if user:
|
||||
# 找到用户但 openid 为空,绑定一下当前 openid
|
||||
user.wechat_openid = request.openid
|
||||
self.user_repository.save(user)
|
||||
# 2. 再按 openid 查找(同一应用内)
|
||||
openid_user = self.user_repository.find_by_wechat_openid(request.openid)
|
||||
|
||||
# 3. 都没找到则创建新用户
|
||||
if not user:
|
||||
if unionid_user and openid_user:
|
||||
# 3a. 两边都命中
|
||||
if unionid_user.id == openid_user.id:
|
||||
# 同一个用户,直接登录
|
||||
user = unionid_user
|
||||
else:
|
||||
# unionid 与 openid 分属两个不同账号:数据异常,拒绝写入,
|
||||
# 交由人工/数据修复合并,避免账号被错误串联
|
||||
return None, ("wechat account conflict: unionid and openid bound to " "different users")
|
||||
elif unionid_user:
|
||||
# 3b. unionid 命中(跨端老用户),当前 openid 未绑定过:
|
||||
# 确认 openid 没有落在其他账号上后,把新 openid 绑到该用户
|
||||
if openid_user is not None and openid_user.id != unionid_user.id:
|
||||
return None, ("wechat account conflict: openid bound to another user")
|
||||
if unionid_user.wechat_openid != request.openid:
|
||||
unionid_user.wechat_openid = request.openid
|
||||
self.user_repository.save(unionid_user)
|
||||
user = unionid_user
|
||||
elif openid_user:
|
||||
# 3c. 仅 openid 命中(开放平台绑定前创建的存量账号):
|
||||
# 本次请求带了 unionid 且该账号还没有 unionid 时补写
|
||||
if request.unionid and not openid_user.wechat_unionid:
|
||||
# 去重:确认该 unionid 没有关联到其他用户
|
||||
conflict = self.user_repository.find_by_wechat_unionid(request.unionid)
|
||||
if conflict is not None and conflict.id != openid_user.id:
|
||||
return None, ("wechat account conflict: unionid already bound to " "another user")
|
||||
openid_user.wechat_unionid = request.unionid
|
||||
self.user_repository.save(openid_user)
|
||||
user = openid_user
|
||||
else:
|
||||
# 4. 都没找到,创建新用户
|
||||
# 额外兜底:若 unionid 已被其他账号占用(理论上上面已查过),
|
||||
# 不创建带冲突 unionid 的新账号
|
||||
user = self._create_wechat_user(request)
|
||||
is_new_user = True
|
||||
|
||||
# 4. 创建 session 并生成 token
|
||||
# 5. 创建 session 并生成 token
|
||||
session_id = secrets.token_urlsafe(16)
|
||||
refresh_token = secrets.token_urlsafe(32)
|
||||
|
||||
@@ -195,11 +227,13 @@ class WechatSyncUseCase:
|
||||
id=user_id,
|
||||
email=email,
|
||||
username=username,
|
||||
display_name=request.nickname or "微信用户",
|
||||
display_name=request.nickname or "小虾同学",
|
||||
password_hash=password_hash,
|
||||
email_verified=True, # 微信登录视为已验证
|
||||
wechat_openid=request.openid,
|
||||
wechat_unionid=request.unionid or None,
|
||||
# 微信新建用户首次登录需引导设置昵称
|
||||
profile_completed=False,
|
||||
)
|
||||
|
||||
self.user_repository.save(user)
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""上传/转码链路(IngestJob + Asset)孤儿清理核心逻辑。
|
||||
|
||||
#1714:generation 链路有 cleanup_stale_running/pending 兜底,但上传链路
|
||||
(ingest_jobs + assets)没有。worker 容器重启/进程 OOM 时,已 prefetch 的
|
||||
celery 消息会丢失(transcode 队列 worker_prefetch_multiplier=1,消息预取后
|
||||
宕机即丢失,Redis 队列里也不再存在),导致:
|
||||
|
||||
- ingest_jobs.status 永久卡 pending/processing
|
||||
- assets.status 永久卡 processing/uploading(complete 阶段预建的占位)
|
||||
|
||||
本模块提供纯核心(session 注入,便于单测):超时阈值内无更新的记录
|
||||
批量标终态(job→failed、asset→error),并返回 (job_id, celery_task_id)
|
||||
列表供调用方 revoke + purge 残留队列消息。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ingest_job PROCESSING 超时阈值:ingest 任务包含下载 + ffprobe + HEVC 转码
|
||||
# (1GB 视频约 10-20 分钟)+ 回传 OSS,正常任务可能跑 20-30 分钟;
|
||||
# 60 分钟阈值覆盖大文件转码 + 抖动,绝不误杀正常任务。
|
||||
INGEST_PROCESSING_TIMEOUT_MINUTES = 60
|
||||
|
||||
# ingest_job PENDING 超时阈值:transcode 队列 concurrency=1,队列积压时
|
||||
# 正常排队可能较久;90 分钟覆盖 worker 短暂停消费 + 排队。
|
||||
INGEST_PENDING_TIMEOUT_MINUTES = 90
|
||||
|
||||
# Asset 占位超时阈值:无关联 ingest_job 的孤儿占位(complete 预建后派单失败等),
|
||||
# 阈值放宽到 120 分钟,避免与 ingest_job 生命周期错杀。
|
||||
ASSET_ORPHAN_TIMEOUT_MINUTES = 120
|
||||
|
||||
_TERMINAL_JOB_STATUSES = ("failed", "completed")
|
||||
_TERMINAL_ASSET_STATUSES = ("ready", "error", "deleted")
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def cleanup_stale_ingest_jobs(
|
||||
session: Any,
|
||||
*,
|
||||
processing_timeout_minutes: int = INGEST_PROCESSING_TIMEOUT_MINUTES,
|
||||
pending_timeout_minutes: int = INGEST_PENDING_TIMEOUT_MINUTES,
|
||||
commit: bool = True,
|
||||
) -> tuple[list[tuple[str, str]], list[str]]:
|
||||
"""清理超时卡 pending/processing 的 ingest_jobs,并联动关联 asset。
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy session(或提供 query/commit 的鸭子类型)
|
||||
processing_timeout_minutes: processing 状态超时阈值
|
||||
pending_timeout_minutes: pending 状态超时阈值
|
||||
commit: 是否提交事务
|
||||
|
||||
Returns:
|
||||
(job_items, asset_ids)
|
||||
- job_items: [(job_id, celery_task_id), ...] 供 revoke/purge
|
||||
- asset_ids: 被联动标记为 error 的 asset id 列表
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel, IngestJobModel
|
||||
|
||||
now = _now()
|
||||
processing_cutoff = now - timedelta(minutes=processing_timeout_minutes)
|
||||
pending_cutoff = now - timedelta(minutes=pending_timeout_minutes)
|
||||
|
||||
stale_jobs = (
|
||||
session.query(IngestJobModel)
|
||||
.filter(
|
||||
IngestJobModel.status.in_(["pending", "processing"]),
|
||||
(
|
||||
(IngestJobModel.status == "processing") & (IngestJobModel.updated_at < processing_cutoff)
|
||||
| (IngestJobModel.status == "pending") & (IngestJobModel.created_at < pending_cutoff)
|
||||
),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
job_items: list[tuple[str, str]] = []
|
||||
asset_ids: list[str] = []
|
||||
stale_asset_models: list[Any] = []
|
||||
for job_model in stale_jobs:
|
||||
ref_time = job_model.updated_at or job_model.created_at
|
||||
if ref_time.tzinfo is None: # SQLite 读回 naive datetime 的防御
|
||||
ref_time = ref_time.replace(tzinfo=timezone.utc)
|
||||
stale_minutes = int((now - ref_time).total_seconds() // 60)
|
||||
job_model.status = "failed"
|
||||
job_model.error_message = (
|
||||
f"转码任务执行中断(超过超时阈值未更新,疑似 worker 重启/进程退出,已卡死 {stale_minutes} 分钟)"
|
||||
)
|
||||
job_model.updated_at = now
|
||||
job_items.append((job_model.id, getattr(job_model, "celery_task_id", "") or ""))
|
||||
if job_model.asset_id:
|
||||
asset_ids.append(job_model.asset_id)
|
||||
|
||||
if asset_ids:
|
||||
stale_asset_models = (
|
||||
session.query(AssetModel)
|
||||
.filter(
|
||||
AssetModel.id.in_(asset_ids),
|
||||
AssetModel.status.in_(["processing", "uploading"]),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for asset_model in stale_asset_models:
|
||||
asset_model.status = "error"
|
||||
asset_model.updated_at = now
|
||||
|
||||
if commit and (job_items or stale_asset_models):
|
||||
session.commit()
|
||||
|
||||
if job_items:
|
||||
logger.warning(
|
||||
"[ingest-cleanup] 清理 %d 个超时 ingest_job(processing>%dm / pending>%dm),联动 %d 个 asset 标 error",
|
||||
len(job_items),
|
||||
processing_timeout_minutes,
|
||||
pending_timeout_minutes,
|
||||
len(stale_asset_models),
|
||||
)
|
||||
return job_items, [a.id for a in stale_asset_models]
|
||||
|
||||
|
||||
def cleanup_orphan_processing_assets(
|
||||
session: Any,
|
||||
*,
|
||||
timeout_minutes: int = ASSET_ORPHAN_TIMEOUT_MINUTES,
|
||||
commit: bool = True,
|
||||
) -> list[str]:
|
||||
"""清理无 ingest_job 关联、超时卡 processing/uploading 的孤儿 asset 占位。
|
||||
|
||||
complete 阶段预建 asset 后若派单失败(或 direct 上传 complete 后
|
||||
未触发 ingest),占位会永久卡住。这类 asset 没有对应 ingest_job,
|
||||
只能按 created_at 超时兜底标 error。
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel, IngestJobModel
|
||||
|
||||
cutoff = _now() - timedelta(minutes=timeout_minutes)
|
||||
orphan_assets = (
|
||||
session.query(AssetModel)
|
||||
.outerjoin(IngestJobModel, IngestJobModel.asset_id == AssetModel.id)
|
||||
.filter(
|
||||
AssetModel.status.in_(["processing", "uploading"]),
|
||||
AssetModel.created_at < cutoff,
|
||||
IngestJobModel.id.is_(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for asset_model in orphan_assets:
|
||||
asset_model.status = "error"
|
||||
asset_model.updated_at = _now()
|
||||
if commit and orphan_assets:
|
||||
session.commit()
|
||||
logger.warning("[ingest-cleanup] 清理 %d 个无 job 关联的超时孤儿 asset 占位", len(orphan_assets))
|
||||
return [a.id for a in orphan_assets]
|
||||
|
||||
|
||||
def revoke_stale_ingest_messages(
|
||||
job_items: list[tuple[str, str]],
|
||||
*,
|
||||
celery_app_factory: Callable[[], Any] | None = None,
|
||||
broker_url_factory: Callable[[], str] | None = None,
|
||||
) -> int:
|
||||
"""revoke + 物理清理 ingest 作废消息(transcode/celery 队列)。
|
||||
|
||||
消息可能已在 worker 宕机时丢失(队列里查不到),那也无害;
|
||||
若消息还在(极端重复投递),物理清除防止重投执行。
|
||||
失败不阻断清理(ingest_asset 的执行前状态守卫是第二道防线)。
|
||||
"""
|
||||
biz_ids = [jid for jid, _ in job_items if jid]
|
||||
celery_ids = [cid for _, cid in job_items if cid]
|
||||
if not biz_ids and not celery_ids:
|
||||
return 0
|
||||
try:
|
||||
from packages.shared.celery_orphan_guard import revoke_and_purge
|
||||
|
||||
app = celery_app_factory() if celery_app_factory else None
|
||||
broker_url = broker_url_factory() if broker_url_factory else ""
|
||||
if app is None or not broker_url:
|
||||
from worker_app.celery_app import celery_app as _app
|
||||
from worker_app.core.config import get_settings
|
||||
|
||||
app = _app
|
||||
broker_url = get_settings().broker_url
|
||||
return revoke_and_purge(
|
||||
app,
|
||||
broker_url,
|
||||
business_task_ids=biz_ids,
|
||||
celery_task_ids=celery_ids,
|
||||
queue_names=("transcode", "celery"),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("撤销作废 ingest 队列消息失败(执行前守卫仍会兜底): %s", e, exc_info=True)
|
||||
return 0
|
||||
|
||||
|
||||
# ── worker 启动恢复(#1714)──────────────────────────────────────────────
|
||||
#
|
||||
# task_acks_late=True 下,worker 崩溃/容器重启时未 ack 的消息理论上会在
|
||||
# visibility_timeout 到期后重新投递;但 prefork 进程异常、部署窗口跨
|
||||
# visibility 配置边界等场景仍可能留下卡在 processing 的 ingest_job
|
||||
# (staging 实证:03:16 派单、03:45 置 processing 后 worker 重启,
|
||||
# unacked 消息未重投,任务永久卡死)。启动时做一次显式恢复扫描兜底。
|
||||
#
|
||||
# 恢复策略:processing 超过 stuck_minutes(默认 10 分钟,部署中跨进程
|
||||
# 交接的正常窗口 < 10 分钟,不会误抢别的 worker 正在执行的任务)的 job,
|
||||
# CAS 重置为 pending 并重新 send_task;旧消息若后来重投,ingest_asset
|
||||
# 的执行前守卫会把状态不匹配的旧 celery 消息丢弃。
|
||||
|
||||
|
||||
def recover_stuck_ingest_jobs_on_startup(
|
||||
session: Any,
|
||||
*,
|
||||
send_task: Callable[..., Any] | None = None,
|
||||
update_celery_task_id: Callable[[str, str], None] | None = None,
|
||||
lock_acquire: Callable[[], bool] | None = None,
|
||||
stuck_minutes: int = 10,
|
||||
commit: bool = True,
|
||||
) -> int:
|
||||
"""worker 启动时把卡在 processing 超时的 ingest_job 重新派单。
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy session
|
||||
send_task: celery send_task 可调用(注入便于测试);不传则用 worker celery_app
|
||||
update_celery_task_id: 回写新 celery task id 的回调(job_id, new_task_id)
|
||||
lock_acquire: 分布式锁获取回调(多 worker 进程同时启动时只允许一个恢复);
|
||||
返回 False 表示未抢到锁,本次跳过
|
||||
stuck_minutes: processing 超过该分钟数视为卡死
|
||||
|
||||
Returns:
|
||||
重新派单的 job 数
|
||||
"""
|
||||
if lock_acquire is not None and not lock_acquire():
|
||||
logger.info("[ingest-recover] 未抢到恢复锁,跳过(另一进程正在恢复)")
|
||||
return 0
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import IngestJobModel
|
||||
|
||||
cutoff = _now() - timedelta(minutes=stuck_minutes)
|
||||
stuck_jobs = (
|
||||
session.query(IngestJobModel)
|
||||
.filter(IngestJobModel.status == "processing", IngestJobModel.updated_at < cutoff)
|
||||
.order_by(IngestJobModel.updated_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
if not stuck_jobs:
|
||||
logger.info("[ingest-recover] 无卡死 processing ingest_job 需要恢复")
|
||||
return 0
|
||||
|
||||
if send_task is None:
|
||||
from worker_app.celery_app import celery_app as _app
|
||||
|
||||
send_task = _app.send_task
|
||||
|
||||
recovered = 0
|
||||
for job_model in stuck_jobs:
|
||||
# CAS:只有仍是 processing 才重置(并发/旧消息已回写终态时不碰)
|
||||
updated = (
|
||||
session.query(IngestJobModel)
|
||||
.filter(IngestJobModel.id == job_model.id, IngestJobModel.status == "processing")
|
||||
.update({"status": "pending", "error_message": "", "updated_at": _now()})
|
||||
)
|
||||
if not updated:
|
||||
continue
|
||||
try:
|
||||
result = send_task("worker.ingest_asset", args=[job_model.id])
|
||||
new_task_id = getattr(result, "id", "") or ""
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("[ingest-recover] 重新派单失败 job_id=%s: %s", job_model.id, e)
|
||||
continue
|
||||
if new_task_id:
|
||||
job_model.celery_task_id = new_task_id
|
||||
if update_celery_task_id is not None:
|
||||
update_celery_task_id(job_model.id, new_task_id)
|
||||
logger.warning(
|
||||
"[ingest-recover] 卡死 ingest_job %s 已重置 pending 并重新派单 (new celery task=%s)",
|
||||
job_model.id,
|
||||
new_task_id,
|
||||
)
|
||||
recovered += 1
|
||||
|
||||
if commit and recovered:
|
||||
session.commit()
|
||||
logger.warning("[ingest-recover] 启动恢复完成,共重新派单 %d 个卡死 ingest_job", recovered)
|
||||
return recovered
|
||||
|
||||
|
||||
def make_redis_recovery_lock(lock_key: str = "ingest:recover:startup", ttl_seconds: int = 300):
|
||||
"""构造基于 Redis SET NX 的恢复锁工厂(多 worker 进程互斥)。
|
||||
|
||||
返回一个无参 callable,调用时尝试抢锁:抢到返回 True,未抢到返回 False。
|
||||
Redis 不可用时不阻断启动恢复(返回 True,恢复逻辑自身有 CAS 幂等保护)。
|
||||
"""
|
||||
|
||||
def _acquire() -> bool:
|
||||
try:
|
||||
import redis as redis_lib
|
||||
from worker_app.core.config import get_settings
|
||||
|
||||
client = redis_lib.Redis.from_url(get_settings().broker_url)
|
||||
return bool(client.set(lock_key, "1", nx=True, ex=ttl_seconds))
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("[ingest-recover] Redis 锁不可用,降级为无锁执行(CAS 兜底): %s", e)
|
||||
return True
|
||||
|
||||
return _acquire
|
||||
@@ -57,6 +57,8 @@ class User:
|
||||
phone: str | None = None
|
||||
phone_verified: bool = False
|
||||
binding_completed_at: datetime | None = None
|
||||
# 资料是否已完善(微信新用户首次设置昵称后置 True;邮箱注册默认 True)
|
||||
profile_completed: bool = True
|
||||
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
@@ -130,6 +131,7 @@ def smart_select_assets(
|
||||
limit: int | None = None,
|
||||
kind: str | None = None,
|
||||
now: datetime | None = None,
|
||||
rng: random.Random | None = None,
|
||||
) -> list[SmartMatchResult]:
|
||||
"""从素材列表中智能选取素材。
|
||||
|
||||
@@ -138,9 +140,11 @@ def smart_select_assets(
|
||||
limit: 最大返回数量,None 表示不限制
|
||||
kind: 按文件类型过滤(video/image/audio),None 表示不过滤
|
||||
now: 当前时间(用于测试注入)
|
||||
rng: 随机数生成器(用于测试注入,控制排序噪声可复现)
|
||||
|
||||
Returns:
|
||||
按得分降序排列的 SmartMatchResult 列表
|
||||
按有效得分(综合得分 + 随机噪声)降序排列的 SmartMatchResult 列表。
|
||||
r.score 始终为无噪声的原始综合得分;噪声仅用于排序/分桶顺序。
|
||||
"""
|
||||
# Step 1: 过滤 ready 状态
|
||||
ready_assets = [a for a in assets if _get_enum_value(a, "status") == "ready"]
|
||||
@@ -158,22 +162,39 @@ def smart_select_assets(
|
||||
total, breakdown = score_asset(a, now=now)
|
||||
scored.append(SmartMatchResult(asset=a, score=total, breakdown=breakdown))
|
||||
|
||||
# Step 4: 按得分降序排序
|
||||
scored.sort(key=lambda r: r.score, reverse=True)
|
||||
# Step 4: 按「得分 + 随机噪声」降序排序
|
||||
# 同分/近分素材(分差 <= SCORE_RANDOM_NOISE_MAX)每次选出的顺序与组合不同,
|
||||
# 从素材组合层面降低成片重复率;分差显著(>20)的高质量素材排名不受影响。
|
||||
# 噪声以 asset.id 为 key 缓存,保证同一次调用内排序与分桶轮询顺序一致。
|
||||
rng = rng or random.Random()
|
||||
noise_by_asset: dict[str, float] = {
|
||||
getattr(a, "id", ""): rng.uniform(0.0, SCORE_RANDOM_NOISE_MAX) for a in ready_assets
|
||||
}
|
||||
|
||||
# Step 5: 多样性保障 — 时长分桶均衡选取
|
||||
def _effective(r: SmartMatchResult) -> float:
|
||||
return r.score + noise_by_asset.get(getattr(r.asset, "id", ""), 0.0)
|
||||
|
||||
scored.sort(key=_effective, reverse=True)
|
||||
|
||||
# Step 5: 多样性保障 — 时长分桶均衡选取(桶内同样按含噪声顺序)
|
||||
if limit and limit > 0 and len(scored) > limit:
|
||||
scored = _diversity_select(scored, limit)
|
||||
scored = _diversity_select(scored, limit, effective_key=_effective)
|
||||
elif limit and limit > 0:
|
||||
scored = scored[:limit]
|
||||
|
||||
return scored
|
||||
|
||||
|
||||
def _diversity_select(scored: list[SmartMatchResult], limit: int) -> list[SmartMatchResult]:
|
||||
def _diversity_select(
|
||||
scored: list[SmartMatchResult],
|
||||
limit: int,
|
||||
effective_key: Any | None = None,
|
||||
) -> list[SmartMatchResult]:
|
||||
"""从已排序的候选中按分桶均衡选取,避免全选中同一时长档。
|
||||
|
||||
策略:轮流从 short/medium/long 桶中按得分顺序取,直到凑满 limit。
|
||||
策略:轮流从 short/medium/long 桶中按顺序取,直到凑满 limit。
|
||||
scored 已按含噪声的有效得分排序,桶内直接继承该顺序;
|
||||
effective_key 给出时最终输出也按有效得分排序(同一次调用内噪声一致)。
|
||||
"""
|
||||
buckets: dict[str, list[SmartMatchResult]] = {
|
||||
"short": [],
|
||||
@@ -209,6 +230,6 @@ def _diversity_select(scored: list[SmartMatchResult], limit: int) -> list[SmartM
|
||||
if not added:
|
||||
break
|
||||
|
||||
# 按原始得分降序输出
|
||||
selected.sort(key=lambda r: r.score, reverse=True)
|
||||
# 按有效得分(含噪声)降序输出;未传 effective_key 时退回原始得分
|
||||
selected.sort(key=effective_key or (lambda r: r.score), reverse=True)
|
||||
return selected
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""批量变体独立选片核心(#1743)。
|
||||
|
||||
总原则:多视频 = 单视频逻辑 × N。批量正式生成/批量预览时,变体 1..N-1
|
||||
不再"克隆源 plan 只重算起点"(那会导致同批素材、同顺序、同速度,成片同源),
|
||||
而是**完整重跑单视频的选片流程**:
|
||||
|
||||
1. 源 plan 片段骨架(clip_type/order/duration/text/transition)保持不变 —— 保留
|
||||
模板结构与用户编辑结果;
|
||||
2. 素材池上做完整随机重选:
|
||||
- 素材组合随机(shuffle 素材池 + smart_match 评分噪声由调用方排序决定);
|
||||
- main 片段之间随机洗牌顺序(片段顺序显著不同);
|
||||
- 起点走场景镜头洗牌 + 随机起点 + 历史已用区间避让
|
||||
(pick_scene_aware_start / _calc_random_start_time,与单视频同一入口);
|
||||
- 跨变体/跨任务避让:get_used_segments 读取素材 metadata 持久化的已用区间,
|
||||
record_used_segments 随新片段写回(同事务),N 个变体串行选片时天然互相避让;
|
||||
3. 批次内片段重叠检查:重选后与"本批次已选定片段"对比,同一 asset 时间区间
|
||||
重叠占比 > 阈值(默认 20%)则该片段重选起点,最多重试若干次。
|
||||
|
||||
本模块只产出 clips_data(dict 列表,供 EditPlanService.replace_all_clips_transactional
|
||||
落库),不碰 DB 事务边界;素材时长/场景点/已用区间由调用方注入,便于单测。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
|
||||
from packages.domain.plan_generator_utils import _resolve_start_time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 阈值常量 ────────────────────────────────────────────────────────────────
|
||||
BATCH_CLIP_OVERLAP_LIMIT = 0.20
|
||||
"""批次内同一素材片段时间区间重叠占比上限(20%)。超过则重选起点。"""
|
||||
|
||||
VARIANT_RESELECT_MAX_ATTEMPTS = 6
|
||||
"""单片段重叠避让/起点重选的最大尝试次数。"""
|
||||
|
||||
MAIN_CLIP_TYPES = {"main"}
|
||||
"""参与素材洗牌重选的片段类型(intro/outro/overlay 等固定角色片段保持源 plan)。"""
|
||||
|
||||
|
||||
def _clip_overlap_ratio(
|
||||
asset_id: str,
|
||||
start: float,
|
||||
duration: float,
|
||||
batch_segments: dict[str, list[tuple[float, float]]],
|
||||
) -> float:
|
||||
"""计算新区间 [start, start+duration) 与批次内同素材已选区间的重叠占比。
|
||||
|
||||
返回重叠总时长 / 片段时长。
|
||||
"""
|
||||
if not asset_id or duration <= 0:
|
||||
return 0.0
|
||||
end = start + duration
|
||||
overlap = 0.0
|
||||
for seg_start, seg_end in batch_segments.get(asset_id, []):
|
||||
ov = max(0.0, min(end, seg_end) - max(start, seg_start))
|
||||
overlap += ov
|
||||
return min(1.0, overlap / duration)
|
||||
|
||||
|
||||
def reselect_clips_for_variant(
|
||||
source_clips: list[dict],
|
||||
candidate_asset_ids: list[str],
|
||||
*,
|
||||
asset_durations: dict[str, float],
|
||||
asset_scene_points: dict[str, list[float]] | None = None,
|
||||
historical_used_segments: dict[str, list[tuple[float, float]]] | None = None,
|
||||
batch_segments: dict[str, list[tuple[float, float]]] | None = None,
|
||||
rng: random.Random | None = None,
|
||||
) -> list[dict]:
|
||||
"""为一个变体基于源片段骨架重新独立选片。
|
||||
|
||||
Args:
|
||||
source_clips: 源 plan 片段(dict 列表,每项至少含
|
||||
order/asset_id/start_time/duration/clip_type,可含
|
||||
playback_speed/transition_effect/transition_duration/text_content)。
|
||||
candidate_asset_ids: 素材池(源 plan 素材 ∪ 批次任务素材),将被 shuffle
|
||||
后随机分配给 main 片段。
|
||||
asset_durations: {asset_id: 时长秒},起点避让/区间计算必需。
|
||||
asset_scene_points: {asset_id: 场景切换点},有则走镜头洗牌选起点。
|
||||
historical_used_segments: 素材 metadata 中持久化的历史已用区间
|
||||
(跨任务/跨变体避让),函数内会就地追加本变体选中的区间。
|
||||
batch_segments: 本批次已选片段区间(变体间避让 + 20% 重叠检查),
|
||||
函数内会就地追加本变体选中的区间。
|
||||
rng: 可选随机数生成器(测试可注入固定种子)。
|
||||
|
||||
Returns:
|
||||
clips_data: 与源片段等长、order 对齐的新片段 dict 列表。
|
||||
|
||||
Raises:
|
||||
ValueError: 源片段为空 / 素材池为空 / 素材时长全为 0(无法差异化选片)。
|
||||
"""
|
||||
rng = rng or random.Random()
|
||||
if not source_clips:
|
||||
raise ValueError("源 plan 无片段,无法为变体重新选片")
|
||||
if not candidate_asset_ids:
|
||||
raise ValueError("素材池为空,无法为变体独立选片(不允许退回同源成片)")
|
||||
|
||||
# 仅保留时长可知(>0)的素材;时长未知无法做区间避让/重叠计算
|
||||
usable_assets = [a for a in dict.fromkeys(candidate_asset_ids) if asset_durations.get(a, 0.0) > 0]
|
||||
if not usable_assets:
|
||||
raise ValueError("素材池时长全部未知(0),无法为变体独立选片")
|
||||
|
||||
# 历史已用区间:复制一份,本变体选中的区间就地追加(随 clip record 持久化由调用方负责)
|
||||
used_segments: dict[str, list[tuple[float, float]]] = (
|
||||
{k: list(v) for k, v in (historical_used_segments or {}).items()} if historical_used_segments else {}
|
||||
)
|
||||
batch_segments = batch_segments if batch_segments is not None else {}
|
||||
|
||||
# 按 order 排序源片段,保持骨架顺序
|
||||
ordered = sorted(source_clips, key=lambda c: c.get("order", 0))
|
||||
|
||||
# ── 1. 素材洗牌:素材池 shuffle(组合随机) ─────────────────────────────
|
||||
shuffled_pool = list(usable_assets)
|
||||
rng.shuffle(shuffled_pool)
|
||||
|
||||
# ── 2. main 片段之间洗牌顺序(顺序随机) ────────────────────────────────
|
||||
main_indexes = [i for i, c in enumerate(ordered) if c.get("clip_type", "main") in MAIN_CLIP_TYPES]
|
||||
rng.shuffle(main_indexes)
|
||||
|
||||
result: list[dict | None] = [None] * len(ordered)
|
||||
pool_cursor = 0
|
||||
|
||||
for idx in main_indexes:
|
||||
src = ordered[idx]
|
||||
dur = float(src.get("duration", 0.0) or 0.0)
|
||||
if dur <= 0:
|
||||
# 异常片段:原样保留
|
||||
result[idx] = _base_clip_data(
|
||||
src, asset_id=src.get("asset_id", ""), start=float(src.get("start_time", 0.0))
|
||||
)
|
||||
continue
|
||||
|
||||
# 轮询取洗牌后素材(素材数 < 片段数时循环复用,但组合/顺序已随机)
|
||||
asset_id = shuffled_pool[pool_cursor % len(shuffled_pool)]
|
||||
pool_cursor += 1
|
||||
total = asset_durations.get(asset_id, 0.0)
|
||||
eff_dur = min(dur, total) if total > 0 else dur
|
||||
|
||||
# ── 3. 起点重选(镜头洗牌/随机起点/历史避让)+ 批次重叠避让 ──────────
|
||||
start = _pick_start_with_overlap_avoid(
|
||||
asset_id=asset_id,
|
||||
clip_duration=eff_dur,
|
||||
asset_durations=asset_durations,
|
||||
used_segments=used_segments,
|
||||
asset_scene_points=asset_scene_points,
|
||||
batch_segments=batch_segments,
|
||||
rng=rng,
|
||||
)
|
||||
|
||||
interval = (start, start + eff_dur)
|
||||
used_segments.setdefault(asset_id, []).append(interval)
|
||||
batch_segments.setdefault(asset_id, []).append(interval)
|
||||
|
||||
result[idx] = _base_clip_data(src, asset_id=asset_id, start=start)
|
||||
|
||||
# ── 4. 非 main 片段(intro/outro/overlay 等固定角色):保留源素材,仅重算起点 ──
|
||||
for idx, c in enumerate(ordered):
|
||||
if result[idx] is not None:
|
||||
continue
|
||||
src = c
|
||||
aid = src.get("asset_id", "")
|
||||
dur = float(src.get("duration", 0.0) or 0.0)
|
||||
start = float(src.get("start_time", 0.0))
|
||||
total = asset_durations.get(aid, 0.0)
|
||||
if aid and dur > 0 and total > 0:
|
||||
eff_dur = min(dur, total)
|
||||
new_start = _pick_start_with_overlap_avoid(
|
||||
asset_id=aid,
|
||||
clip_duration=eff_dur,
|
||||
asset_durations=asset_durations,
|
||||
used_segments=used_segments,
|
||||
asset_scene_points=asset_scene_points,
|
||||
batch_segments=batch_segments,
|
||||
rng=rng,
|
||||
)
|
||||
start = new_start
|
||||
interval = (start, start + eff_dur)
|
||||
used_segments.setdefault(aid, []).append(interval)
|
||||
batch_segments.setdefault(aid, []).append(interval)
|
||||
result[idx] = _base_clip_data(src, asset_id=aid, start=start)
|
||||
|
||||
return [c for c in result if c is not None]
|
||||
|
||||
|
||||
def _pick_start_with_overlap_avoid(
|
||||
*,
|
||||
asset_id: str,
|
||||
clip_duration: float,
|
||||
asset_durations: dict[str, float],
|
||||
used_segments: dict[str, list[tuple[float, float]]],
|
||||
asset_scene_points: dict[str, list[float]] | None,
|
||||
batch_segments: dict[str, list[tuple[float, float]]],
|
||||
rng: random.Random,
|
||||
) -> float:
|
||||
"""选起点:优先单视频同一入口(镜头洗牌/随机/历史避让),再叠加批次 20% 重叠避让。
|
||||
|
||||
批次内重叠超阈值时在素材可用范围内随机抖动重选,最多 VARIANT_RESELECT_MAX_ATTEMPTS 次;
|
||||
仍超阈值则返回最后一次结果(素材极少时的尽力而为,不阻塞生成)。
|
||||
"""
|
||||
total = asset_durations.get(asset_id, 0.0)
|
||||
max_start = max(0.0, total - clip_duration)
|
||||
|
||||
candidate = _resolve_start_time(
|
||||
asset_id,
|
||||
clip_duration,
|
||||
asset_durations,
|
||||
used_segments,
|
||||
asset_scene_points,
|
||||
)
|
||||
if candidate is None:
|
||||
candidate = rng.uniform(0.0, max_start) if max_start > 0 else 0.0
|
||||
|
||||
best_start = candidate
|
||||
best_ratio = _clip_overlap_ratio(asset_id, candidate, clip_duration, batch_segments)
|
||||
if best_ratio <= BATCH_CLIP_OVERLAP_LIMIT:
|
||||
return candidate
|
||||
|
||||
# 重叠超阈值:在可用范围内随机重试
|
||||
for _ in range(VARIANT_RESELECT_MAX_ATTEMPTS):
|
||||
alt = rng.uniform(0.0, max_start) if max_start > 0 else 0.0
|
||||
ratio = _clip_overlap_ratio(asset_id, alt, clip_duration, batch_segments)
|
||||
if ratio < best_ratio:
|
||||
best_start, best_ratio = alt, ratio
|
||||
if ratio <= BATCH_CLIP_OVERLAP_LIMIT:
|
||||
return alt
|
||||
logger.info(
|
||||
"变体选片批次重叠避让达上限,采用最优起点: asset=%s overlap_ratio=%.2f",
|
||||
asset_id,
|
||||
best_ratio,
|
||||
)
|
||||
return best_start
|
||||
|
||||
|
||||
def _base_clip_data(src: dict, *, asset_id: str, start: float) -> dict:
|
||||
"""从源片段构造落库 dict(保留骨架/转场/文案/速度,替换素材与起点)。"""
|
||||
return {
|
||||
"order": src.get("order", 0),
|
||||
"asset_id": asset_id,
|
||||
"start_time": round(float(start), 3),
|
||||
"duration": float(src.get("duration", 0.0) or 0.0),
|
||||
"clip_type": src.get("clip_type", "main"),
|
||||
"playback_speed": float(src.get("playback_speed", 1.0) or 1.0),
|
||||
"transition_effect": src.get("transition_effect", "cut"),
|
||||
"transition_duration": float(src.get("transition_duration", 0.0) or 0.0),
|
||||
"text_content": src.get("text_content", ""),
|
||||
"config": src.get("config") or {},
|
||||
}
|
||||
@@ -347,8 +347,9 @@ class TestCreateGenerationTask:
|
||||
assert mock_celery.send_task.call_args[0][0] == "worker.generate_video"
|
||||
|
||||
@patch("app.core.task_enqueue.celery_app")
|
||||
def test_create_batch_tasks(self, mock_celery, client):
|
||||
"""批量创建多个生成任务。"""
|
||||
def test_create_batch_tasks_without_plan_rejected(self, mock_celery, client):
|
||||
"""#1743:批量 count=3 但无剪辑计划(未传 source_edit_plan_id 且模板兜底无 plan)
|
||||
→ 400 中断,严禁 N 任务兜底共用同一 plan 产出同源成片。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
resp = client.post(
|
||||
@@ -361,17 +362,44 @@ class TestCreateGenerationTask:
|
||||
"count": 3,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.status_code == 400
|
||||
assert "预览" in resp.json()["detail"] or "剪辑计划" in resp.json()["detail"]
|
||||
mock_celery.send_task.assert_not_called()
|
||||
|
||||
@patch("app.core.task_enqueue.celery_app")
|
||||
def test_create_batch_tasks_with_independent_variant_plans(self, mock_celery, client):
|
||||
"""#1743:批量 count=3 且有源 plan → 变体 0 用源 plan,变体 1/2 各自 reselect
|
||||
独立选片,3 个任务关联 3 个不同 plan。"""
|
||||
mock_celery.send_task = MagicMock()
|
||||
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = [
|
||||
MagicMock(id="variant-plan-1"),
|
||||
MagicMock(id="variant-plan-2"),
|
||||
]
|
||||
resp = client.post(
|
||||
"/api/v1/generation/tasks",
|
||||
json={
|
||||
"project_id": "proj-1",
|
||||
"asset_library_id": "lib-1",
|
||||
"strategy_id": "strategy-default",
|
||||
"voice_library_id": "voice-lib-1",
|
||||
"source_edit_plan_id": "source-plan-1",
|
||||
"count": 3,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 3
|
||||
assert data["total"] == 3
|
||||
# 验证所有任务都有不同的 ID
|
||||
task_ids = [t["id"] for t in data["items"]]
|
||||
assert len(set(task_ids)) == 3
|
||||
# 同一批次应有相同的 batch_id
|
||||
# 同一批次 batch_id 相同
|
||||
batch_ids = [t["batch_id"] for t in data["items"] if t["batch_id"]]
|
||||
assert len(batch_ids) == 3
|
||||
assert len(set(batch_ids)) == 1
|
||||
# 变体 1/2 各自独立选片
|
||||
assert MockPlanSvc.return_value.reselect_plan_for_variant.call_count == 2
|
||||
|
||||
def test_create_task_project_not_found(self, client):
|
||||
"""项目不存在返回 404。"""
|
||||
|
||||
@@ -189,30 +189,32 @@ class TestBatchPreviewRoute:
|
||||
for i, item in enumerate(resp.items):
|
||||
assert item.variant_index == i
|
||||
|
||||
def test_preview_count_3_clones_three_variant_plans(self):
|
||||
"""有源 plan 时,N=3 克隆 3 个独立变体 plan(预览全部克隆,不用源 plan)"""
|
||||
def test_preview_count_3_reselects_independent_variant_plans(self):
|
||||
"""#1743:有源 plan 时 N=3,变体0保留源 plan,变体1/2 各自独立选片(reselect)。"""
|
||||
from app.api.routes.generation_preview import create_preview_generation_task
|
||||
|
||||
tasks = [_make_task(task_id=f"task_{i}", source_plan_id="source_plan") for i in range(3)]
|
||||
repo = _repo_mock()
|
||||
cloned_plan_ids = ["clone_1", "clone_2", "clone_3"]
|
||||
reselect_plan_ids = ["reselect_1", "reselect_2"]
|
||||
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
|
||||
MockUC.return_value.execute.side_effect = tasks
|
||||
with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True):
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
clone_results = [MagicMock(id=pid) for pid in cloned_plan_ids]
|
||||
MockPlanSvc.return_value.clone_plan_for_variant.side_effect = clone_results
|
||||
reselect_results = [MagicMock(id=pid) for pid in reselect_plan_ids]
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = reselect_results
|
||||
create_preview_generation_task(
|
||||
_make_preview_request(preview_count=3),
|
||||
authenticated_user=_make_user(),
|
||||
generation_task_repository=repo,
|
||||
db=MagicMock(),
|
||||
)
|
||||
# 克隆被调用 3 次
|
||||
assert MockPlanSvc.return_value.clone_plan_for_variant.call_count == 3
|
||||
# 每个任务关联到不同的克隆 plan
|
||||
for i, task in enumerate(tasks):
|
||||
assert task.source_edit_plan_id == cloned_plan_ids[i]
|
||||
# 变体 1..N-1 各独立选片一次(共 2 次);count>1 不再走 clone
|
||||
assert MockPlanSvc.return_value.reselect_plan_for_variant.call_count == 2
|
||||
MockPlanSvc.return_value.clone_plan_for_variant.assert_not_called()
|
||||
# 变体0保留源 plan;变体1/2 关联各自独立选出的 plan
|
||||
assert tasks[0].source_edit_plan_id == "source_plan"
|
||||
assert tasks[1].source_edit_plan_id == "reselect_1"
|
||||
assert tasks[2].source_edit_plan_id == "reselect_2"
|
||||
|
||||
def test_preview_variant_titles_injected_per_variant(self):
|
||||
"""titles[] 按变体注入 title_config.text"""
|
||||
@@ -335,8 +337,8 @@ class TestBatchPreviewRoute:
|
||||
)
|
||||
assert exc.value.status_code == 429
|
||||
|
||||
def test_preview_clone_failure_marks_all_failed(self):
|
||||
"""克隆变体 plan 失败 → 已创建任务全部标记 failed 并 500"""
|
||||
def test_preview_reselect_failure_marks_all_failed(self):
|
||||
"""#1743:变体独立选片(reselect)重试仍失败 → 已创建任务全部标记 failed 并 500"""
|
||||
from app.api.routes.generation_preview import create_preview_generation_task
|
||||
from fastapi import HTTPException
|
||||
|
||||
@@ -345,7 +347,7 @@ class TestBatchPreviewRoute:
|
||||
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
|
||||
MockUC.return_value.execute.side_effect = tasks
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.clone_plan_for_variant.side_effect = RuntimeError("db down")
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = RuntimeError("db down")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
create_preview_generation_task(
|
||||
_make_preview_request(preview_count=3),
|
||||
@@ -357,6 +359,73 @@ class TestBatchPreviewRoute:
|
||||
# 所有已创建任务都被标记 failed
|
||||
assert all(t.status == GenerationTaskStatus.FAILED for t in tasks)
|
||||
|
||||
def test_preview_reselect_value_error_returns_400(self):
|
||||
"""#1743:预览 count>1 reselect 素材不足(ValueError)→ 400,已建任务标 failed。"""
|
||||
from app.api.routes.generation_preview import create_preview_generation_task
|
||||
from fastapi import HTTPException
|
||||
|
||||
tasks = [_make_task(task_id=f"task_{i}", source_plan_id="source_plan") for i in range(3)]
|
||||
repo = _repo_mock()
|
||||
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
|
||||
MockUC.return_value.execute.side_effect = tasks
|
||||
with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True):
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = ValueError("素材池为空")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
create_preview_generation_task(
|
||||
_make_preview_request(preview_count=3),
|
||||
authenticated_user=_make_user(),
|
||||
generation_task_repository=repo,
|
||||
db=MagicMock(),
|
||||
)
|
||||
assert exc.value.status_code == 400
|
||||
assert "无法独立选片" in exc.value.detail
|
||||
assert all(t.status == GenerationTaskStatus.FAILED for t in tasks)
|
||||
|
||||
def test_preview_reselect_retry_exhausted_returns_500(self):
|
||||
"""#1743:预览 count>1 reselect 连续失败(非 ValueError)→ 500,已建任务标 failed。"""
|
||||
from app.api.routes.generation_preview import create_preview_generation_task
|
||||
from fastapi import HTTPException
|
||||
|
||||
tasks = [_make_task(task_id=f"task_{i}", source_plan_id="source_plan") for i in range(3)]
|
||||
repo = _repo_mock()
|
||||
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
|
||||
MockUC.return_value.execute.side_effect = tasks
|
||||
with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True):
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = RuntimeError("db down")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
create_preview_generation_task(
|
||||
_make_preview_request(preview_count=3),
|
||||
authenticated_user=_make_user(),
|
||||
generation_task_repository=repo,
|
||||
db=MagicMock(),
|
||||
)
|
||||
assert exc.value.status_code == 500
|
||||
assert all(t.status == GenerationTaskStatus.FAILED for t in tasks)
|
||||
|
||||
def test_preview_count1_with_source_plan_clones(self):
|
||||
"""#1743 零回归:预览 count=1 且有源 plan 仍走 clone(不 reselect)。"""
|
||||
from app.api.routes.generation_preview import create_preview_generation_task
|
||||
|
||||
task = _make_task(task_id="task_1", source_plan_id="source_plan")
|
||||
repo = _repo_mock()
|
||||
with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC:
|
||||
MockUC.return_value.execute.return_value = task
|
||||
with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True):
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.clone_plan_for_variant.return_value = MagicMock(id="clone_1")
|
||||
resp = create_preview_generation_task(
|
||||
_make_preview_request(preview_count=1),
|
||||
authenticated_user=_make_user(),
|
||||
generation_task_repository=repo,
|
||||
db=MagicMock(),
|
||||
)
|
||||
assert resp.total == 1
|
||||
MockPlanSvc.return_value.clone_plan_for_variant.assert_called_once()
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.assert_not_called()
|
||||
assert task.source_edit_plan_id == "clone_1"
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 批量正式生成:变体配置注入
|
||||
@@ -366,7 +435,7 @@ class TestBatchPreviewRoute:
|
||||
class TestBatchGenerationVariantConfig:
|
||||
"""POST /tasks count=N 时变体独立配置。"""
|
||||
|
||||
def _call_create_tasks(self, request, repo=None):
|
||||
def _call_create_tasks(self, request, repo=None, db_latest_plan=None):
|
||||
from app.api.routes.generation_tasks import create_generation_task
|
||||
|
||||
repo = repo or MagicMock()
|
||||
@@ -379,9 +448,9 @@ class TestBatchGenerationVariantConfig:
|
||||
asset_repo = MagicMock()
|
||||
asset_repo.find_by_id.return_value = None
|
||||
|
||||
# db.query().filter()...first() 返回 None:不走兜底关联编辑计划
|
||||
# db.query().filter()...first():db_latest_plan 非空时模拟模板兜底查到最新 plan
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
|
||||
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = db_latest_plan
|
||||
|
||||
return create_generation_task(
|
||||
request,
|
||||
@@ -407,20 +476,29 @@ class TestBatchGenerationVariantConfig:
|
||||
t.title_config = cmd.title_config
|
||||
t.voice_library_id = cmd.voice_library_id
|
||||
t.cover_url = cmd.cover_url
|
||||
t.source_edit_plan_id = cmd.source_edit_plan_id # #1743:usecase 落库关联 plan
|
||||
return t
|
||||
|
||||
MockUC.return_value.execute.side_effect = _execute
|
||||
with patch.object(routes, "safe_enqueue_generation_task", return_value=True):
|
||||
req = CreateGenerationTaskRequest(
|
||||
template_id="tpl_1",
|
||||
asset_ids=["a1"],
|
||||
count=3,
|
||||
title_config={"font": "宋体"},
|
||||
titles=["成片标题1", "成片标题2", "成片标题3"],
|
||||
voice_library_ids=["v1", "v2", "v3"],
|
||||
cover_urls=["http://c1", "http://c2", "http://c3"],
|
||||
)
|
||||
resp = self._call_create_tasks(req)
|
||||
# #1743:count>1 必须有源 plan,变体 1..N-1 走 reselect 独立选片
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = [
|
||||
MagicMock(id="reselect_1"),
|
||||
MagicMock(id="reselect_2"),
|
||||
]
|
||||
req = CreateGenerationTaskRequest(
|
||||
template_id="tpl_1",
|
||||
asset_ids=["a1"],
|
||||
count=3,
|
||||
source_edit_plan_id="source_plan",
|
||||
title_config={"font": "宋体"},
|
||||
titles=["成片标题1", "成片标题2", "成片标题3"],
|
||||
voice_library_ids=["v1", "v2", "v3"],
|
||||
cover_urls=["http://c1", "http://c2", "http://c3"],
|
||||
)
|
||||
resp = self._call_create_tasks(req)
|
||||
assert MockPlanSvc.return_value.reselect_plan_for_variant.call_count == 2
|
||||
assert resp.total == 3
|
||||
assert [c.title_config["text"] for c in captured] == ["成片标题1", "成片标题2", "成片标题3"]
|
||||
assert [c.voice_library_id for c in captured] == ["v1", "v2", "v3"]
|
||||
@@ -467,21 +545,124 @@ class TestBatchGenerationVariantConfig:
|
||||
|
||||
def _execute(cmd):
|
||||
captured.append(cmd)
|
||||
return tasks[len(captured) - 1]
|
||||
t = tasks[len(captured) - 1]
|
||||
t.source_edit_plan_id = cmd.source_edit_plan_id # #1743:usecase 落库关联 plan
|
||||
return t
|
||||
|
||||
MockUC.return_value.execute.side_effect = _execute
|
||||
with patch.object(routes, "safe_enqueue_generation_task", return_value=True):
|
||||
req = CreateGenerationTaskRequest(
|
||||
template_id="tpl_1",
|
||||
asset_ids=["a1"],
|
||||
count=3,
|
||||
voice_library_ids=["shared_voice"],
|
||||
cover_urls=["http://shared"],
|
||||
)
|
||||
self._call_create_tasks(req)
|
||||
# #1743:count>1 必须有源 plan,变体 1..N-1 走 reselect 独立选片
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = [
|
||||
MagicMock(id="reselect_1"),
|
||||
MagicMock(id="reselect_2"),
|
||||
]
|
||||
req = CreateGenerationTaskRequest(
|
||||
template_id="tpl_1",
|
||||
asset_ids=["a1"],
|
||||
count=3,
|
||||
source_edit_plan_id="source_plan",
|
||||
voice_library_ids=["shared_voice"],
|
||||
cover_urls=["http://shared"],
|
||||
)
|
||||
self._call_create_tasks(req)
|
||||
assert MockPlanSvc.return_value.reselect_plan_for_variant.call_count == 2
|
||||
assert all(c.voice_library_id == "shared_voice" for c in captured)
|
||||
assert all(c.cover_url == "http://shared" for c in captured)
|
||||
|
||||
def test_count3_template_fallback_plan_used(self):
|
||||
"""#1743:未传 source_edit_plan_id 时,模板兜底查到最新 plan 即作为批量源 plan。"""
|
||||
from app.api.routes import generation_tasks as routes
|
||||
|
||||
tasks = [_make_task(task_id=f"gen_fb_{i}") for i in range(3)]
|
||||
|
||||
def _execute(cmd):
|
||||
t = tasks[len([c for c in getattr(_execute, "caps", [])])]
|
||||
t.source_edit_plan_id = cmd.source_edit_plan_id
|
||||
_execute.caps.append(cmd)
|
||||
return t
|
||||
|
||||
_execute.caps = []
|
||||
|
||||
latest = MagicMock(id="fallback_plan_id")
|
||||
req = CreateGenerationTaskRequest(template_id="tpl_1", asset_ids=["a1"], count=3)
|
||||
|
||||
with patch.object(routes, "CreateGenerationTaskUseCase") as MockUC:
|
||||
MockUC.return_value.execute.side_effect = _execute
|
||||
with patch.object(routes, "safe_enqueue_generation_task", return_value=True):
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = [
|
||||
MagicMock(id="reselect_1"),
|
||||
MagicMock(id="reselect_2"),
|
||||
]
|
||||
self._call_create_tasks(req, db_latest_plan=latest)
|
||||
|
||||
# 兜底 plan 被用作源;变体0关联兜底 plan,变体1/2关联 reselect plan
|
||||
assert _execute.caps[0].source_edit_plan_id == "fallback_plan_id"
|
||||
assert _execute.caps[1].source_edit_plan_id == "reselect_1"
|
||||
assert _execute.caps[2].source_edit_plan_id == "reselect_2"
|
||||
|
||||
def test_count3_reselect_value_error_returns_400(self):
|
||||
"""#1743:reselect 素材不足(ValueError)→ 400 明确报错,零任务入队。"""
|
||||
from app.api.routes import generation_tasks as routes
|
||||
from fastapi import HTTPException
|
||||
|
||||
enqueue = MagicMock(return_value=True)
|
||||
req = CreateGenerationTaskRequest(
|
||||
template_id="tpl_1", asset_ids=["a1"], count=3, source_edit_plan_id="source_plan"
|
||||
)
|
||||
with patch.object(routes, "CreateGenerationTaskUseCase") as MockUC:
|
||||
MockUC.return_value.execute.side_effect = lambda cmd: _make_task(task_id="should_not_run")
|
||||
with patch.object(routes, "safe_enqueue_generation_task", enqueue):
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = ValueError("素材池为空")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
self._call_create_tasks(req)
|
||||
assert exc.value.status_code == 400
|
||||
assert "无法独立选片" in exc.value.detail
|
||||
enqueue.assert_not_called()
|
||||
|
||||
def test_count3_reselect_retry_exhausted_returns_500(self):
|
||||
"""#1743:reselect 连续 2 次都非 ValueError 失败 → 500,零任务入队。"""
|
||||
from app.api.routes import generation_tasks as routes
|
||||
from fastapi import HTTPException
|
||||
|
||||
enqueue = MagicMock(return_value=True)
|
||||
req = CreateGenerationTaskRequest(
|
||||
template_id="tpl_1", asset_ids=["a1"], count=3, source_edit_plan_id="source_plan"
|
||||
)
|
||||
with patch.object(routes, "CreateGenerationTaskUseCase") as MockUC:
|
||||
MockUC.return_value.execute.side_effect = lambda cmd: _make_task(task_id="should_not_run")
|
||||
with patch.object(routes, "safe_enqueue_generation_task", enqueue):
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = RuntimeError("db down")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
self._call_create_tasks(req)
|
||||
assert exc.value.status_code == 500
|
||||
enqueue.assert_not_called()
|
||||
|
||||
def test_count3_missing_plan_after_prebuild_raises_500(self):
|
||||
"""#1743 兜底守卫:任务落库时 plan 丢失(usecase 未透传)→ 500,严禁静默共用。"""
|
||||
from app.api.routes import generation_tasks as routes
|
||||
from fastapi import HTTPException
|
||||
|
||||
req = CreateGenerationTaskRequest(
|
||||
template_id="tpl_1", asset_ids=["a1"], count=3, source_edit_plan_id="source_plan"
|
||||
)
|
||||
with patch.object(routes, "CreateGenerationTaskUseCase") as MockUC:
|
||||
# usecase 返回的任务 source_edit_plan_id 为空(模拟落库丢 plan)
|
||||
MockUC.return_value.execute.side_effect = lambda cmd: _make_task(task_id="lost_plan")
|
||||
with patch.object(routes, "safe_enqueue_generation_task", return_value=True):
|
||||
with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc:
|
||||
MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = [
|
||||
MagicMock(id="reselect_1"),
|
||||
MagicMock(id="reselect_2"),
|
||||
]
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
self._call_create_tasks(req)
|
||||
assert exc.value.status_code == 500
|
||||
assert "变体剪辑计划缺失" in exc.value.detail
|
||||
|
||||
|
||||
class TestVariantValueHelper:
|
||||
"""_variant_value 取值逻辑。"""
|
||||
|
||||
@@ -420,8 +420,9 @@ class TestSmartMatchFlatStructure:
|
||||
"""item.id 直接在顶层可读,不存在 item.asset 包装层。"""
|
||||
assets = [_fresh_asset("a-flat-1"), _fresh_asset("a-flat-2")]
|
||||
resp = TestSmartMatchFiltersExhausted()._call(assets)
|
||||
ids = [item.id for item in resp.items]
|
||||
assert ids == ["a-flat-1", "a-flat-2"]
|
||||
ids = {item.id for item in resp.items}
|
||||
# 同分素材排序含随机噪声(#1743),只断言集合不断言顺序
|
||||
assert ids == {"a-flat-1", "a-flat-2"}
|
||||
# 嵌套 asset 字段已移除
|
||||
assert all(not hasattr(item, "asset") for item in resp.items)
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""#1714 find_recent_active_by_library_and_name 严格模式测试。
|
||||
|
||||
file_size=0(未知)时必须返回 None(宁可漏判不可误杀);
|
||||
大小严格匹配;只命中近期 UPLOADING/PROCESSING 记录。
|
||||
"""
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository # noqa: E402
|
||||
from packages.adapters.sqlalchemy_impl.models import Base # noqa: E402
|
||||
from packages.domain import Asset, AssetStatus # noqa: E402
|
||||
|
||||
|
||||
def _repository():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
session = sessionmaker(bind=engine)()
|
||||
return SQLAlchemyAssetRepository(session)
|
||||
|
||||
|
||||
def _mk_asset(name="IMG_2285.MOV", file_size=5_000_000, status=AssetStatus.PROCESSING, minutes_ago=5):
|
||||
asset = Asset.create(
|
||||
project_id="proj-1",
|
||||
library_id="lib-1",
|
||||
name=name,
|
||||
storage_key=f"uploads/x/{name}",
|
||||
mime_type="video/quicktime",
|
||||
file_size=file_size,
|
||||
)
|
||||
asset.status = status
|
||||
asset.created_at = datetime.now(timezone.utc) - timedelta(minutes=minutes_ago)
|
||||
return asset
|
||||
|
||||
|
||||
def test_returns_none_when_file_size_zero():
|
||||
"""file_size=0(大小未知)直接返回 None——不许仅凭同名 + processing 判重。"""
|
||||
repo = _repository()
|
||||
repo.create(_mk_asset(file_size=0))
|
||||
|
||||
result = repo.find_recent_active_by_library_and_name(library_id="lib-1", name="IMG_2285.MOV", file_size=0)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_matches_when_name_size_strict_equal():
|
||||
"""同名 + 同大小 + processing 近期记录 → 命中。"""
|
||||
repo = _repository()
|
||||
repo.create(_mk_asset(file_size=5_000_000))
|
||||
|
||||
result = repo.find_recent_active_by_library_and_name(library_id="lib-1", name="IMG_2285.MOV", file_size=5_000_000)
|
||||
assert result is not None
|
||||
assert result.name == "IMG_2285.MOV"
|
||||
|
||||
|
||||
def test_no_match_when_same_name_but_different_size():
|
||||
"""同名但大小不同 → 不命中(内容全新的视频不能误杀)。"""
|
||||
repo = _repository()
|
||||
repo.create(_mk_asset(file_size=5_000_000))
|
||||
|
||||
result = repo.find_recent_active_by_library_and_name(library_id="lib-1", name="IMG_2285.MOV", file_size=9_999_999)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_no_match_ready_history_even_with_same_size():
|
||||
"""READY 历史同名素材不命中(允许再次上传同名文件)。"""
|
||||
repo = _repository()
|
||||
repo.create(_mk_asset(file_size=5_000_000, status=AssetStatus.READY))
|
||||
|
||||
result = repo.find_recent_active_by_library_and_name(library_id="lib-1", name="IMG_2285.MOV", file_size=5_000_000)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_no_match_when_window_expired():
|
||||
"""超过 30 分钟窗口的活动记录不命中。"""
|
||||
repo = _repository()
|
||||
repo.create(_mk_asset(file_size=5_000_000, minutes_ago=45))
|
||||
|
||||
result = repo.find_recent_active_by_library_and_name(
|
||||
library_id="lib-1", name="IMG_2285.MOV", within_minutes=30, file_size=5_000_000
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_returns_none_when_name_empty():
|
||||
repo = _repository()
|
||||
result = repo.find_recent_active_by_library_and_name(library_id="lib-1", name="", file_size=100)
|
||||
assert result is None
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -19,6 +20,20 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.api.routes.generation_tasks import _select_assets_from_library
|
||||
|
||||
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX
|
||||
|
||||
|
||||
class _ZeroNoiseRandom(random.Random):
|
||||
"""零噪声随机源:uniform(0, NOISE_MAX) 恒返回 0,smart 排序确定可复现。"""
|
||||
|
||||
def uniform(self, a, b):
|
||||
if a == 0.0 and b == SCORE_RANDOM_NOISE_MAX:
|
||||
return 0.0
|
||||
return super().uniform(a, b)
|
||||
|
||||
|
||||
_ZERO_NOISE = _ZeroNoiseRandom(0)
|
||||
|
||||
from packages.domain import Asset, AssetStatus
|
||||
|
||||
|
||||
@@ -106,7 +121,7 @@ class TestSelectAssetsSmartMode:
|
||||
_asset("high", "high.mp4", quality_score=90),
|
||||
_asset("mid", "mid.mp4", quality_score=60),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0)
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0, rng=_ZERO_NOISE)
|
||||
assert result == ["high", "mid", "low"]
|
||||
|
||||
def test_smart_duration_optimal_beats_too_short(self):
|
||||
@@ -115,7 +130,7 @@ class TestSelectAssetsSmartMode:
|
||||
_asset("too_short", "short.mp4", quality_score=80, duration=1.0),
|
||||
_asset("optimal", "optimal.mp4", quality_score=80, duration=15.0),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0)
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0, rng=_ZERO_NOISE)
|
||||
# Both: quality=80*0.4=32, recency/unused equal
|
||||
# optimal(15s): duration_fitness=30 → total=62+
|
||||
# too_short(1s): duration_fitness=20+(1/5)*80=36 → 36*0.3=10.8 → total=42.8+
|
||||
@@ -127,7 +142,7 @@ class TestSelectAssetsSmartMode:
|
||||
_asset("a2", "v2.mp4", quality_score=70),
|
||||
_asset("a3", "v3.mp4", quality_score=50),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="smart", count=2)
|
||||
result = _select_assets_from_library(assets, mode="smart", count=2, rng=_ZERO_NOISE)
|
||||
assert result == ["a1", "a2"]
|
||||
|
||||
def test_smart_null_quality_treated_as_default(self):
|
||||
@@ -136,7 +151,7 @@ class TestSelectAssetsSmartMode:
|
||||
_asset("scored", "scored.mp4", quality_score=80),
|
||||
_asset("unscored", "unscored.mp4", quality_score=None),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0)
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0, rng=_ZERO_NOISE)
|
||||
# scored(80): quality=80*0.4=32; unscored(None→50): quality=50*0.4=20
|
||||
assert result == ["scored", "unscored"]
|
||||
|
||||
@@ -146,7 +161,7 @@ class TestSelectAssetsSmartMode:
|
||||
_asset("a2", "v2.mp4", quality_score=90),
|
||||
_asset("a3", "v3.mp4", quality_score=50),
|
||||
]
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0)
|
||||
result = _select_assets_from_library(assets, mode="smart", count=0, rng=_ZERO_NOISE)
|
||||
assert result == ["a2", "a3", "a1"]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""#1743 素材使用次数回写测试。
|
||||
|
||||
计数口径修复:
|
||||
- mark_asset_used_for_generation 支持 times 参数,按成片实际片段引用次数累加
|
||||
- _count_plan_clip_asset_usage 从最终成片 plan 的 clips 统计 {asset_id: 片段引用次数}
|
||||
- _record_rendered_asset_usage 回写 metadata:plan clips 为准、空 plan 兜底任务 asset_ids、
|
||||
未被 plan 选用的素材不计数、单素材失败不影响其他素材
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
for sub in ("apps/worker", "apps/api", "packages", ""):
|
||||
p = str(REPO_ROOT / sub) if sub else str(REPO_ROOT)
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
import worker_app.tasks.generation as gen_mod # noqa: E402
|
||||
from worker_app.core.asset_usage import mark_asset_used_for_generation # noqa: E402
|
||||
|
||||
|
||||
class FakeAsset:
|
||||
def __init__(self, aid: str, metadata: dict | None = None):
|
||||
self.id = aid
|
||||
self.metadata = metadata or {}
|
||||
|
||||
|
||||
# ── mark_asset_used_for_generation ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMarkAssetUsed:
|
||||
def test_default_times_is_one(self):
|
||||
a = FakeAsset("a1", metadata={})
|
||||
mark_asset_used_for_generation(a)
|
||||
assert a.metadata["generation_use_count"] == 1
|
||||
assert "last_used_at" in a.metadata
|
||||
|
||||
def test_times_accumulates_by_clip_count(self):
|
||||
"""同一素材被 3 个片段引用 → 一次回写 +3。"""
|
||||
a = FakeAsset("a1", metadata={"generation_use_count": 2})
|
||||
mark_asset_used_for_generation(a, times=3)
|
||||
assert a.metadata["generation_use_count"] == 5
|
||||
|
||||
def test_times_zero_or_negative_floored_to_one(self):
|
||||
a = FakeAsset("a1", metadata={})
|
||||
mark_asset_used_for_generation(a, times=0)
|
||||
assert a.metadata["generation_use_count"] == 1
|
||||
|
||||
def test_preserves_existing_metadata(self):
|
||||
a = FakeAsset("a1", metadata={"tags": ["travel"], "generation_use_count": 4})
|
||||
mark_asset_used_for_generation(a, times=2)
|
||||
assert a.metadata["tags"] == ["travel"]
|
||||
assert a.metadata["generation_use_count"] == 6
|
||||
|
||||
|
||||
# ── _count_plan_clip_asset_usage ────────────────────────────────────────────
|
||||
|
||||
|
||||
def _mock_session_with_clips(clip_asset_ids: list[str]):
|
||||
"""构造 mock session:query(EditPlanClipModel).filter().all() 返回片段 asset_id 行。"""
|
||||
rows = [(aid,) for aid in clip_asset_ids]
|
||||
session = MagicMock()
|
||||
session.query.return_value.filter.return_value.all.return_value = rows
|
||||
return session
|
||||
|
||||
|
||||
class TestCountPlanClipUsage:
|
||||
def test_counts_asset_occurrences_across_clips(self):
|
||||
"""plan clips 中 asset-x 出现 2 次、asset-y 1 次 → {x:2, y:1}。"""
|
||||
session = _mock_session_with_clips(["asset-x", "asset-y", "asset-x", ""])
|
||||
with _patch_clip_model():
|
||||
counts = gen_mod._count_plan_clip_asset_usage(session, "plan-1")
|
||||
assert counts == {"asset-x": 2, "asset-y": 1}
|
||||
|
||||
def test_empty_clips_returns_empty(self):
|
||||
session = _mock_session_with_clips([])
|
||||
with _patch_clip_model():
|
||||
counts = gen_mod._count_plan_clip_asset_usage(session, "plan-9")
|
||||
assert counts == {}
|
||||
|
||||
def test_blank_asset_ids_skipped(self):
|
||||
session = _mock_session_with_clips(["", None, "asset-z"])
|
||||
with _patch_clip_model():
|
||||
counts = gen_mod._count_plan_clip_asset_usage(session, "plan-1")
|
||||
assert counts == {"asset-z": 1}
|
||||
|
||||
|
||||
class _patch_clip_model:
|
||||
"""patch generation 模块内 EditPlanClipModel 的导入路径(函数内 import)。"""
|
||||
|
||||
def __enter__(self):
|
||||
import unittest.mock as mock
|
||||
|
||||
self._patches = [
|
||||
mock.patch("packages.adapters.sqlalchemy_impl.models.EditPlanClipModel", MagicMock()),
|
||||
]
|
||||
for p in self._patches:
|
||||
p.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
for p in self._patches:
|
||||
p.stop()
|
||||
return False
|
||||
|
||||
|
||||
# ── _record_rendered_asset_usage ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRecordRenderedAssetUsage:
|
||||
def _run(self, clip_ids, fallback_ids=None, repo_get_override=None, repo_update_side=None):
|
||||
session = _mock_session_with_clips(clip_ids)
|
||||
assets: dict[str, FakeAsset] = {}
|
||||
|
||||
def fake_repo_init(sess):
|
||||
repo = MagicMock()
|
||||
|
||||
def fake_get(aid):
|
||||
if repo_get_override and aid in repo_get_override:
|
||||
return repo_get_override[aid]
|
||||
return assets.setdefault(aid, FakeAsset(aid, metadata={}))
|
||||
|
||||
repo.get.side_effect = fake_get
|
||||
if repo_update_side:
|
||||
repo.update.side_effect = repo_update_side
|
||||
else:
|
||||
repo.update.side_effect = lambda a: a
|
||||
return repo
|
||||
|
||||
import unittest.mock as mock
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"packages.adapters.sqlalchemy_impl.asset_repository.SQLAlchemyAssetRepository",
|
||||
side_effect=fake_repo_init,
|
||||
),
|
||||
_patch_clip_model(),
|
||||
):
|
||||
written = gen_mod._record_rendered_asset_usage(session, "plan-1", "task-1", fallback_asset_ids=fallback_ids)
|
||||
return written, assets
|
||||
|
||||
def test_writes_by_plan_clips_not_request_asset_ids(self):
|
||||
"""核心口径:plan clips 用 x×2 + y×1;请求里的 z(未被 plan 选用)不计数。"""
|
||||
written, assets = self._run(
|
||||
clip_ids=["asset-x", "asset-x", "asset-y"],
|
||||
fallback_ids=["asset-x", "asset-y", "asset-z"],
|
||||
)
|
||||
assert written == 2
|
||||
assert assets["asset-x"].metadata["generation_use_count"] == 2
|
||||
assert assets["asset-y"].metadata["generation_use_count"] == 1
|
||||
assert "asset-z" not in assets, "未被 plan 选用的素材不应被计数"
|
||||
|
||||
def test_fallback_to_task_asset_ids_when_plan_empty(self):
|
||||
"""plan 无有效片段(异常数据)→ 兜底任务 asset_ids 每个计 1 次。"""
|
||||
written, assets = self._run(clip_ids=[""], fallback_ids=["asset-a", "asset-b"])
|
||||
assert written == 2
|
||||
assert assets["asset-a"].metadata["generation_use_count"] == 1
|
||||
assert assets["asset-b"].metadata["generation_use_count"] == 1
|
||||
|
||||
def test_no_clips_no_fallback_returns_zero(self):
|
||||
written, assets = self._run(clip_ids=[], fallback_ids=[])
|
||||
assert written == 0
|
||||
assert assets == {}
|
||||
|
||||
def test_single_asset_failure_does_not_block_others(self):
|
||||
"""单素材 update 抛异常不影响其他素材回写。"""
|
||||
|
||||
def update_side(a):
|
||||
if a.id == "bad":
|
||||
raise RuntimeError("DB boom")
|
||||
return a
|
||||
|
||||
written, assets = self._run(
|
||||
clip_ids=["good1", "bad", "good2"],
|
||||
fallback_ids=None,
|
||||
repo_update_side=update_side,
|
||||
)
|
||||
assert written == 2
|
||||
assert assets["good1"].metadata["generation_use_count"] == 1
|
||||
assert assets["good2"].metadata["generation_use_count"] == 1
|
||||
|
||||
def test_missing_asset_skipped(self):
|
||||
"""repo.get 返回 None 的素材跳过,不报错。"""
|
||||
written, assets = self._run(
|
||||
clip_ids=["ghost", "real"],
|
||||
fallback_ids=None,
|
||||
repo_get_override={"ghost": None},
|
||||
)
|
||||
assert written == 1
|
||||
assert assets["real"].metadata["generation_use_count"] == 1
|
||||
@@ -0,0 +1,244 @@
|
||||
"""#1743 dedup_helpers 批次查重 + worker 重选 plan 重试函数测试。
|
||||
|
||||
覆盖:
|
||||
- 批次任务且无历史重复时走 check_batch_duplicate,batch_similarity 透传到返回 dict
|
||||
- 批次任务历史已重复 → 不再做批次查重,is_duplicate=True / duplicate_of 透传
|
||||
- 非批次任务 batch_similarity 恒为 None
|
||||
- _reselect_plan_for_batch_retry:成功返回新 plan_id;异常返回 None(保留首版)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
for sub in ("apps/worker", "apps/api", "packages", ""):
|
||||
p = str(REPO_ROOT / sub) if sub else str(REPO_ROOT)
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
from packages.adapters.sqlalchemy_impl.models import Base # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
s = sessionmaker(bind=engine)()
|
||||
try:
|
||||
yield s
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def _dedup_kwargs(batch_id):
|
||||
return dict(
|
||||
generation_task_id="task-batch-1",
|
||||
project_id="proj-1",
|
||||
user_id="user-1",
|
||||
batch_id=batch_id,
|
||||
file_url="https://oss.example.com/v.mp4",
|
||||
file_size=1024,
|
||||
duration=10.0,
|
||||
video_path="/tmp/fake.mp4",
|
||||
mode="edit_plan",
|
||||
width=1280,
|
||||
height=720,
|
||||
fps=25.0,
|
||||
)
|
||||
|
||||
|
||||
class TestBatchDedupInHelpers:
|
||||
"""dedup_helpers 内部 lazy import video_processing.dedup(依赖 cv2),
|
||||
复用 test_generated_video_creation_logic 的 mock 模块注册模式。"""
|
||||
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
import sys
|
||||
|
||||
if "cv2" not in sys.modules:
|
||||
sys.modules["cv2"] = MagicMock()
|
||||
|
||||
# 保存 setup 前的真实模块引用,teardown 原样还原(沙箱无 cv2 时 reimport 会失败,
|
||||
# 若直接 pop 掉 MagicMock 会让后续测试(如 test_dedup_1702)拿到残缺模块)
|
||||
import video_processing
|
||||
|
||||
cls._orig_dedup = sys.modules.get("video_processing.dedup")
|
||||
cls._orig_dedup_attr = getattr(video_processing, "dedup", None)
|
||||
cls._orig_thumb = sys.modules.get("video_processing.thumbnail_generator")
|
||||
cls._orig_thumb_attr = getattr(video_processing, "thumbnail_generator", None)
|
||||
|
||||
mock_dedup = MagicMock()
|
||||
mock_dedup.VideoDeduplicator = MagicMock()
|
||||
sys.modules["video_processing.dedup"] = mock_dedup
|
||||
|
||||
mock_thumb = MagicMock()
|
||||
mock_thumb.extract_first_frame = MagicMock()
|
||||
sys.modules["video_processing.thumbnail_generator"] = mock_thumb
|
||||
|
||||
video_processing.dedup = mock_dedup
|
||||
video_processing.thumbnail_generator = mock_thumb
|
||||
|
||||
@classmethod
|
||||
def teardown_class(cls):
|
||||
import sys
|
||||
|
||||
import video_processing
|
||||
|
||||
# 还原 setup 前状态:原本有真模块→放回;原本没有→移除 mock
|
||||
if cls._orig_dedup is not None:
|
||||
sys.modules["video_processing.dedup"] = cls._orig_dedup
|
||||
else:
|
||||
sys.modules.pop("video_processing.dedup", None)
|
||||
if cls._orig_dedup_attr is not None:
|
||||
video_processing.dedup = cls._orig_dedup_attr
|
||||
elif hasattr(video_processing, "dedup"):
|
||||
delattr(video_processing, "dedup")
|
||||
|
||||
if cls._orig_thumb is not None:
|
||||
sys.modules["video_processing.thumbnail_generator"] = cls._orig_thumb
|
||||
else:
|
||||
sys.modules.pop("video_processing.thumbnail_generator", None)
|
||||
if cls._orig_thumb_attr is not None:
|
||||
video_processing.thumbnail_generator = cls._orig_thumb_attr
|
||||
elif hasattr(video_processing, "thumbnail_generator"):
|
||||
delattr(video_processing, "thumbnail_generator")
|
||||
|
||||
def test_batch_similarity_returned_when_batch_duplicate_found(self, session):
|
||||
"""批次任务 + 无历史重复 + 批次查重命中 → 返回 batch_similarity 与 is_duplicate。"""
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
with patch("video_processing.dedup.VideoDeduplicator") as mock_cls:
|
||||
dedup = mock_cls.return_value
|
||||
dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {})
|
||||
dedup.check_duplicate.return_value = None # 历史无重复
|
||||
dedup.check_batch_duplicate.return_value = {
|
||||
"duplicate_of": "video-existing",
|
||||
"reason": "batch_similar",
|
||||
"similarity": 0.601,
|
||||
}
|
||||
dedup.compute_duplicate_rate.return_value = {
|
||||
"duplicate_rate": 0.0,
|
||||
"visual_similarity": 0.0,
|
||||
"match_count": 0,
|
||||
}
|
||||
|
||||
result = create_video_record_and_dedup(session=session, **_dedup_kwargs("batch-abc"))
|
||||
|
||||
assert result["video_count"] == 1
|
||||
assert result["batch_similarity"] == pytest.approx(0.601)
|
||||
assert result["is_duplicate"] is True
|
||||
assert result["duplicate_of"] == "video-existing"
|
||||
# 批次查重确实被调用(历史查重为 None 才走批次)
|
||||
dedup.check_batch_duplicate.assert_called_once()
|
||||
|
||||
def test_batch_check_skipped_when_historical_duplicate(self, session):
|
||||
"""历史查重已命中 → 不再批次查重,batch_similarity 为 None。"""
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
with patch("video_processing.dedup.VideoDeduplicator") as mock_cls:
|
||||
dedup = mock_cls.return_value
|
||||
dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {})
|
||||
dedup.check_duplicate.return_value = {
|
||||
"duplicate_of": "video-old",
|
||||
"reason": "global",
|
||||
"similarity": 0.85,
|
||||
}
|
||||
dedup.compute_duplicate_rate.return_value = {
|
||||
"duplicate_rate": 85.0,
|
||||
"visual_similarity": 0.85,
|
||||
"match_count": 3,
|
||||
}
|
||||
|
||||
result = create_video_record_and_dedup(session=session, **_dedup_kwargs("batch-abc"))
|
||||
|
||||
assert result["is_duplicate"] is True
|
||||
assert result["duplicate_of"] == "video-old"
|
||||
assert result["batch_similarity"] is None
|
||||
dedup.check_batch_duplicate.assert_not_called()
|
||||
|
||||
def test_non_batch_never_runs_batch_check(self, session):
|
||||
"""非批次任务(batch_id 为空)→ 不调用批次查重,batch_similarity None。"""
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
with patch("video_processing.dedup.VideoDeduplicator") as mock_cls:
|
||||
dedup = mock_cls.return_value
|
||||
dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {})
|
||||
dedup.check_duplicate.return_value = None
|
||||
dedup.check_batch_duplicate.return_value = {"similarity": 0.99}
|
||||
dedup.compute_duplicate_rate.return_value = {
|
||||
"duplicate_rate": 0.0,
|
||||
"visual_similarity": 0.0,
|
||||
"match_count": 0,
|
||||
}
|
||||
|
||||
result = create_video_record_and_dedup(session=session, **_dedup_kwargs(""))
|
||||
|
||||
assert result["video_count"] == 1
|
||||
assert result["batch_similarity"] is None
|
||||
assert result["is_duplicate"] is False
|
||||
dedup.check_batch_duplicate.assert_not_called()
|
||||
|
||||
def test_batch_no_duplicate_returns_none_similarity(self, session):
|
||||
"""批次任务但批次查重也未命中 → batch_similarity None(驱动不重渲)。"""
|
||||
from video_processing.dedup_helpers import create_video_record_and_dedup
|
||||
|
||||
with patch("video_processing.dedup.VideoDeduplicator") as mock_cls:
|
||||
dedup = mock_cls.return_value
|
||||
dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {})
|
||||
dedup.check_duplicate.return_value = None
|
||||
dedup.check_batch_duplicate.return_value = None
|
||||
dedup.compute_duplicate_rate.return_value = {
|
||||
"duplicate_rate": 0.0,
|
||||
"visual_similarity": 0.0,
|
||||
"match_count": 0,
|
||||
}
|
||||
|
||||
result = create_video_record_and_dedup(session=session, **_dedup_kwargs("batch-xyz"))
|
||||
|
||||
assert result["batch_similarity"] is None
|
||||
assert result["is_duplicate"] is False
|
||||
|
||||
|
||||
class TestReselectPlanForBatchRetry:
|
||||
def test_success_returns_new_plan_id(self):
|
||||
"""重选成功 → 返回新 plan_id。"""
|
||||
from worker_app.tasks.generation import _reselect_plan_for_batch_retry
|
||||
|
||||
fake_svc = MagicMock()
|
||||
fake_svc.reselect_plan_for_variant.return_value = MagicMock(id="new-plan-999")
|
||||
with patch("app.services.edit_plan_service.EditPlanService", return_value=fake_svc):
|
||||
with patch("worker_app.tasks.generation.SessionLocal") as mock_session_local:
|
||||
mock_session_local.return_value = MagicMock()
|
||||
new_id = _reselect_plan_for_batch_retry(
|
||||
"task-1",
|
||||
"old-plan",
|
||||
{"task_asset_ids": ["a1", "a2"], "user_id": "u-1"},
|
||||
)
|
||||
assert new_id == "new-plan-999"
|
||||
fake_svc.reselect_plan_for_variant.assert_called_once()
|
||||
args, kwargs = fake_svc.reselect_plan_for_variant.call_args
|
||||
assert args[0] == "old-plan"
|
||||
assert args[1] == ["a1", "a2"]
|
||||
assert kwargs["created_by_user_id"] == "u-1"
|
||||
|
||||
def test_failure_returns_none_and_keeps_first_version(self):
|
||||
"""重选抛异常 → 返回 None(调用方放弃重渲、保留首版),不抛出。"""
|
||||
from worker_app.tasks.generation import _reselect_plan_for_batch_retry
|
||||
|
||||
fake_svc = MagicMock()
|
||||
fake_svc.reselect_plan_for_variant.side_effect = RuntimeError("db down")
|
||||
with patch("app.services.edit_plan_service.EditPlanService", return_value=fake_svc):
|
||||
with patch("worker_app.tasks.generation.SessionLocal") as mock_session_local:
|
||||
mock_session_local.return_value = MagicMock()
|
||||
result = _reselect_plan_for_batch_retry("task-1", "old-plan", {"task_asset_ids": [], "user_id": "u-1"})
|
||||
assert result is None
|
||||
@@ -1,7 +1,9 @@
|
||||
"""AI Review 回归:批量生成 count>1 但 source_edit_plan_id 为空时不应 IndexError。
|
||||
"""#1743 批量生成无可用 plan 守卫。
|
||||
|
||||
变体 plan 预克隆仅在 source_edit_plan_id 非空时执行;无源 plan 时
|
||||
variant_plan_ids 为空,循环中禁止索引访问,各任务走自身随机选片流程。
|
||||
新规则(P0 降重):count>1 批量生成时必须存在源 plan(前端传入或按模板兜底
|
||||
解析到最新 plan),为每个变体独立选片;**无任何可用 plan 时直接 4xx 中断、
|
||||
不创建任务**,严禁 N 个任务兜底共用同一 plan 产出同源成片。
|
||||
N=1 单视频不受影响(无 plan 时走原有单任务流程)。
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -9,6 +11,9 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
@@ -18,78 +23,117 @@ def _make_user():
|
||||
return SimpleNamespace(user=SimpleNamespace(id="user-1"))
|
||||
|
||||
|
||||
def _make_request(count):
|
||||
def _make_request(count, **overrides):
|
||||
from app.schemas.generation_task import CreateGenerationTaskRequest
|
||||
|
||||
return CreateGenerationTaskRequest(
|
||||
fields = dict(
|
||||
project_id="proj-1",
|
||||
asset_library_id="lib-1",
|
||||
strategy_id="one_take",
|
||||
asset_ids=["a1"],
|
||||
count=count,
|
||||
source_edit_plan_id="", # 关键:无源 plan(空字符串为假值)
|
||||
source_edit_plan_id="",
|
||||
)
|
||||
fields.update(overrides)
|
||||
return CreateGenerationTaskRequest(**fields)
|
||||
|
||||
|
||||
class TestBatchNoSourcePlanNoIndexError:
|
||||
def test_count3_without_source_plan_creates_three_tasks(self):
|
||||
"""count=3 且无 source_edit_plan_id:不克隆、不 IndexError、创建 3 个任务。"""
|
||||
def _common_patches(latest_plan=None):
|
||||
"""构造通用 patch 上下文(repo/usecase/enqueue 等)。latest_plan 为模板兜底 plan 或 None。"""
|
||||
repo = MagicMock()
|
||||
repo.count_pending_by_user.return_value = 0
|
||||
repo.count_pending_total.return_value = 0
|
||||
repo.create.side_effect = lambda t: t
|
||||
repo.update.side_effect = lambda t: t
|
||||
|
||||
created = []
|
||||
|
||||
def _fake_execute(cmd):
|
||||
task = MagicMock()
|
||||
task.id = f"task-{len(created) + 1}"
|
||||
task.source_edit_plan_id = cmd.source_edit_plan_id
|
||||
task.status = "pending"
|
||||
task.progress = 0.0
|
||||
task.strategy_id = "one_take"
|
||||
task.error_message = ""
|
||||
task.cover_url = ""
|
||||
task.title_config = {}
|
||||
task.created_at = None
|
||||
task.batch_id = "batch-1"
|
||||
created.append(task)
|
||||
return task
|
||||
|
||||
db = MagicMock()
|
||||
# 模板兜底查最新 plan:返回 latest_plan(None 表示查不到)
|
||||
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = latest_plan
|
||||
|
||||
mock_uc = patch("app.api.routes.generation_tasks.CreateGenerationTaskUseCase")
|
||||
other_patches = [
|
||||
patch("app.api.routes.generation_tasks.safe_enqueue_generation_task", return_value=True),
|
||||
patch("app.api.routes.generation_tasks._writeback_edit_plan_config"),
|
||||
patch(
|
||||
"app.api.routes.generation_tasks._resolve_project_and_library",
|
||||
return_value=("proj-1", ""),
|
||||
),
|
||||
]
|
||||
return repo, db, created, mock_uc, other_patches, _fake_execute
|
||||
|
||||
|
||||
class TestBatchNoSourcePlanGuard:
|
||||
def test_count3_without_any_plan_rejects_4xx_and_creates_nothing(self):
|
||||
"""count=3 且无源 plan、模板兜底也查不到 → 400 中断,零任务创建(严禁同源成片)。"""
|
||||
from app.api.routes.generation_tasks import create_generation_task
|
||||
|
||||
repo = MagicMock()
|
||||
repo.count_pending_by_user.return_value = 0
|
||||
repo.count_pending_total.return_value = 0
|
||||
repo.create.side_effect = lambda t: t
|
||||
repo.update.side_effect = lambda t: t
|
||||
repo, db, created, mock_uc, other_patches, fake_exec = _common_patches(latest_plan=None)
|
||||
MockUC = mock_uc.start()
|
||||
MockUC.return_value.execute.side_effect = fake_exec
|
||||
for p in other_patches:
|
||||
p.start()
|
||||
all_patches = [mock_uc] + other_patches
|
||||
try:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_generation_task(
|
||||
_make_request(3),
|
||||
authenticated_user=_make_user(),
|
||||
generation_task_repository=repo,
|
||||
project_repository=MagicMock(),
|
||||
asset_repository=MagicMock(),
|
||||
asset_library_repository=MagicMock(),
|
||||
db=db,
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
finally:
|
||||
for p in reversed(all_patches):
|
||||
p.stop()
|
||||
assert len(created) == 0, "无 plan 批量必须零任务创建"
|
||||
|
||||
created = []
|
||||
def test_count1_without_plan_still_works(self):
|
||||
"""N=1 单视频无 plan 不触发批量守卫(向后兼容,不 4xx)。"""
|
||||
from app.api.routes.generation_tasks import create_generation_task
|
||||
|
||||
def _fake_execute(cmd):
|
||||
task = MagicMock()
|
||||
task.id = f"task-{len(created) + 1}"
|
||||
task.source_edit_plan_id = cmd.source_edit_plan_id
|
||||
task.status = "pending"
|
||||
task.progress = 0.0
|
||||
task.strategy_id = "one_take"
|
||||
task.error_message = ""
|
||||
task.cover_url = None
|
||||
task.title_config = {}
|
||||
task.created_at = None
|
||||
task.batch_id = "batch-1"
|
||||
created.append(task)
|
||||
return task
|
||||
|
||||
with patch("app.api.routes.generation_tasks.CreateGenerationTaskUseCase") as MockUC:
|
||||
MockUC.return_value.execute.side_effect = _fake_execute
|
||||
with patch(
|
||||
"app.api.routes.generation_tasks.safe_enqueue_generation_task",
|
||||
return_value=True,
|
||||
):
|
||||
with patch("app.api.routes.generation_tasks._writeback_edit_plan_config"):
|
||||
with patch(
|
||||
"app.api.routes.generation_tasks._resolve_project_and_library",
|
||||
return_value=("proj-1", ""),
|
||||
):
|
||||
# 核心断言:不得抛 IndexError(变体 plan 索引守卫)。
|
||||
# 响应序列化字段与本回归无关,ValidationError 可接受,
|
||||
# 但 IndexError 必须不出现。
|
||||
try:
|
||||
create_generation_task(
|
||||
_make_request(3),
|
||||
authenticated_user=_make_user(),
|
||||
generation_task_repository=repo,
|
||||
project_repository=MagicMock(),
|
||||
asset_repository=MagicMock(),
|
||||
asset_library_repository=MagicMock(),
|
||||
db=MagicMock(),
|
||||
)
|
||||
except IndexError as exc: # pragma: no cover - 不应发生
|
||||
pytest.fail(f"无源 plan 批量生成触发 IndexError: {exc}")
|
||||
except Exception:
|
||||
# 响应序列化等其他异常与本次守卫无关,忽略
|
||||
pass
|
||||
|
||||
# 3 个任务全部创建(未因 IndexError 中断)
|
||||
assert len(created) == 3
|
||||
# 无源 plan 时所有任务 source_edit_plan_id 均为空
|
||||
assert all(not t.source_edit_plan_id for t in created)
|
||||
repo, db, created, mock_uc, other_patches, fake_exec = _common_patches(latest_plan=None)
|
||||
MockUC = mock_uc.start()
|
||||
MockUC.return_value.execute.side_effect = fake_exec
|
||||
for p in other_patches:
|
||||
p.start()
|
||||
all_patches = [mock_uc] + other_patches
|
||||
try:
|
||||
create_generation_task(
|
||||
_make_request(1),
|
||||
authenticated_user=_make_user(),
|
||||
generation_task_repository=repo,
|
||||
project_repository=MagicMock(),
|
||||
asset_repository=MagicMock(),
|
||||
asset_library_repository=MagicMock(),
|
||||
db=db,
|
||||
)
|
||||
except HTTPException as e:
|
||||
assert e.status_code != 400, f"N=1 不应被批量守卫拦截: {e.detail}"
|
||||
except Exception:
|
||||
# MagicMock 任务对象下游响应序列化可能抛 ValidationError 等,与批量守卫无关;
|
||||
# 任务已在 usecase.execute 中创建,下方断言 created==1 即证明守卫未拦截。
|
||||
pass
|
||||
finally:
|
||||
for p in reversed(all_patches):
|
||||
p.stop()
|
||||
assert len(created) == 1
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""#1743 worker 批量重渲判定 + 封面哈希选帧纯函数测试。
|
||||
|
||||
- should_rerender_for_batch_dedup:批次首版查重率 >20% 才重渲(非批次/重渲版/无查重率不重渲)
|
||||
- pick_batch_cover_index:task_id md5 稳定哈希分散候选帧(同任务稳定、跨任务分散)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
for sub in ("apps/worker", "apps/api", "packages", ""):
|
||||
p = str(REPO_ROOT / sub) if sub else str(REPO_ROOT)
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
from worker_app.tasks.generation import ( # noqa: E402
|
||||
BATCH_RENDER_SIMILARITY_LIMIT,
|
||||
pick_batch_cover_index,
|
||||
should_rerender_for_batch_dedup,
|
||||
)
|
||||
|
||||
|
||||
class TestShouldRerenderForBatchDedup:
|
||||
def test_non_batch_never_rerenders(self):
|
||||
"""非批次任务(batch_id 为空)任何查重率都不重渲。"""
|
||||
assert should_rerender_for_batch_dedup(batch_id="", render_attempt=0, batch_similarity=0.99) is False
|
||||
assert should_rerender_for_batch_dedup(batch_id="", render_attempt=0, batch_similarity=None) is False
|
||||
|
||||
def test_batch_first_version_over_threshold_rerenders(self):
|
||||
"""批次首版(attempt=0)查重率 >20% → 重渲。"""
|
||||
assert should_rerender_for_batch_dedup(batch_id="batch-1", render_attempt=0, batch_similarity=0.601) is True
|
||||
assert should_rerender_for_batch_dedup(batch_id="batch-1", render_attempt=0, batch_similarity=0.21) is True
|
||||
|
||||
def test_batch_first_version_under_threshold_no_rerender(self):
|
||||
"""批次首版查重率 ≤20% → 不重渲。"""
|
||||
assert should_rerender_for_batch_dedup(batch_id="batch-1", render_attempt=0, batch_similarity=0.20) is False
|
||||
assert should_rerender_for_batch_dedup(batch_id="batch-1", render_attempt=0, batch_similarity=0.0) is False
|
||||
assert should_rerender_for_batch_dedup(batch_id="batch-1", render_attempt=0, batch_similarity=0.05) is False
|
||||
|
||||
def test_rerendered_version_never_rerenders_again(self):
|
||||
"""重渲版(attempt=1)即使仍超阈值也不再重渲(最多重渲一次)。"""
|
||||
assert should_rerender_for_batch_dedup(batch_id="batch-1", render_attempt=1, batch_similarity=0.99) is False
|
||||
|
||||
def test_missing_similarity_no_rerender(self):
|
||||
"""查重率缺失(None,非批次查重路径)→ 不重渲。"""
|
||||
assert should_rerender_for_batch_dedup(batch_id="batch-1", render_attempt=0, batch_similarity=None) is False
|
||||
|
||||
def test_threshold_constant_is_20_percent(self):
|
||||
assert BATCH_RENDER_SIMILARITY_LIMIT == 0.20
|
||||
|
||||
|
||||
class TestPickBatchCoverIndex:
|
||||
def test_stable_for_same_task(self):
|
||||
"""同一 task_id 多次调用结果稳定(重试封面不变)。"""
|
||||
first = pick_batch_cover_index("task-abc", 3)
|
||||
for _ in range(5):
|
||||
assert pick_batch_cover_index("task-abc", 3) == first
|
||||
|
||||
def test_zero_or_one_candidate_returns_zero(self):
|
||||
assert pick_batch_cover_index("task-x", 0) == 0
|
||||
assert pick_batch_cover_index("task-x", 1) == 0
|
||||
|
||||
def test_index_within_range(self):
|
||||
for i in range(20):
|
||||
idx = pick_batch_cover_index(f"task-{i}", 3)
|
||||
assert 0 <= idx < 3
|
||||
|
||||
def test_matches_md5_formula(self):
|
||||
"""与主流程公式一致:md5(task_id) % 候选数。"""
|
||||
task_id = "task-formula-check"
|
||||
expected = int(hashlib.md5(task_id.encode()).hexdigest(), 16) % 3
|
||||
assert pick_batch_cover_index(task_id, 3) == expected
|
||||
|
||||
def test_batch_tasks_spread_across_frames(self):
|
||||
"""30 个批次任务在 3 个候选帧上分散(不能全部落在 frame_0——旧 bug 回归守卫)。"""
|
||||
indexes = {pick_batch_cover_index(f"batch-task-{i}", 3) for i in range(30)}
|
||||
assert len(indexes) >= 2, f"封面帧应分散到多个帧位,实际全部落在: {indexes}"
|
||||
@@ -0,0 +1,75 @@
|
||||
"""#1714 beat 任务 scheduled_cleanup_stale_ingest_jobs 薄封装测试。
|
||||
|
||||
mock SessionLocal 和清理核心,验证 beat 任务正确串联
|
||||
cleanup_stale_ingest_jobs → cleanup_orphan_processing_assets → revoke 消息。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test_beat.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
import worker_app.tasks.cleanup as cleanup # noqa: E402
|
||||
|
||||
|
||||
def test_beat_cleanup_calls_core_and_revokes():
|
||||
"""beat 任务串联三个核心步骤,返回汇总计数。"""
|
||||
fake_session = MagicMock()
|
||||
|
||||
with (
|
||||
patch("worker_app.db.SessionLocal", return_value=fake_session) as m_db,
|
||||
patch(
|
||||
"packages.application.ingest_orphan_cleanup.cleanup_stale_ingest_jobs",
|
||||
return_value=([("job-1", "cel-1"), ("job-2", "")], ["a-1"]),
|
||||
) as m_jobs,
|
||||
patch(
|
||||
"packages.application.ingest_orphan_cleanup.cleanup_orphan_processing_assets",
|
||||
return_value=["a-2"],
|
||||
) as m_assets,
|
||||
patch(
|
||||
"packages.shared.celery_orphan_guard.revoke_and_purge",
|
||||
return_value=1,
|
||||
) as m_revoke,
|
||||
):
|
||||
result = cleanup.scheduled_cleanup_stale_ingest_jobs()
|
||||
|
||||
m_db.assert_called_once()
|
||||
m_jobs.assert_called_once()
|
||||
assert m_jobs.call_args.kwargs["processing_timeout_minutes"] == 60
|
||||
m_assets.assert_called_once()
|
||||
m_revoke.assert_called_once()
|
||||
# 队列名只传 transcode/celery(不传 generation)
|
||||
assert m_revoke.call_args.kwargs["queue_names"] == ("transcode", "celery")
|
||||
fake_session.close.assert_called_once()
|
||||
assert result == {"stale_jobs": 2, "assets_to_error": 2, "purged_messages": 1}
|
||||
|
||||
|
||||
def test_beat_cleanup_no_op_when_nothing_stale():
|
||||
"""无孤儿时不调 revoke,返回全 0。"""
|
||||
fake_session = MagicMock()
|
||||
|
||||
with (
|
||||
patch("worker_app.db.SessionLocal", return_value=fake_session),
|
||||
patch(
|
||||
"packages.application.ingest_orphan_cleanup.cleanup_stale_ingest_jobs",
|
||||
return_value=([], []),
|
||||
),
|
||||
patch(
|
||||
"packages.application.ingest_orphan_cleanup.cleanup_orphan_processing_assets",
|
||||
return_value=[],
|
||||
),
|
||||
patch("packages.shared.celery_orphan_guard.revoke_and_purge") as m_revoke,
|
||||
):
|
||||
result = cleanup.scheduled_cleanup_stale_ingest_jobs()
|
||||
|
||||
m_revoke.assert_not_called()
|
||||
assert result == {"stale_jobs": 0, "assets_to_error": 0, "purged_messages": 0}
|
||||
@@ -85,7 +85,10 @@ class TestTwoPhaseCommit:
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result == 1
|
||||
# #1743:返回值由 int 改为 dict(含批次查重信息)
|
||||
assert isinstance(result, dict)
|
||||
assert result["video_count"] == 1
|
||||
assert result["batch_similarity"] is None # 非批次任务无批次相似度
|
||||
# create() should be called exactly once with the complete video object
|
||||
mock_repo.create.assert_called_once()
|
||||
created_video = mock_repo.create.call_args[0][0]
|
||||
@@ -122,7 +125,10 @@ class TestTwoPhaseCommit:
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result == 1
|
||||
# #1743:返回值由 int 改为 dict(含批次查重信息)
|
||||
assert isinstance(result, dict)
|
||||
assert result["video_count"] == 1
|
||||
assert result["batch_similarity"] is None # 非批次任务无批次相似度
|
||||
mock_repo.create.assert_called_once()
|
||||
created_video = mock_repo.create.call_args[0][0]
|
||||
assert created_video.duplicate_rate is None
|
||||
@@ -161,7 +167,10 @@ class TestTwoPhaseCommit:
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result == 1
|
||||
# #1743:返回值由 int 改为 dict(含批次查重信息)
|
||||
assert isinstance(result, dict)
|
||||
assert result["video_count"] == 1
|
||||
assert result["batch_similarity"] is None # 非批次任务无批次相似度
|
||||
mock_repo.create.assert_called_once()
|
||||
created_video = mock_repo.create.call_args[0][0]
|
||||
# Fingerprint should be set
|
||||
@@ -229,6 +238,7 @@ class TestTwoPhaseCommit:
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result == 0
|
||||
# #1743:失败路径返回 dict(video_count=0)
|
||||
assert result["video_count"] == 0
|
||||
session.commit.assert_not_called()
|
||||
session.rollback.assert_called_once()
|
||||
|
||||
@@ -381,7 +381,8 @@ class TestThumbnailInDedupHelpers:
|
||||
thumbnail_url=pre_thumb_url,
|
||||
)
|
||||
|
||||
assert result == 1
|
||||
# #1743:返回 dict(非批次 batch_similarity=None)
|
||||
assert result["video_count"] == 1
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
@@ -427,7 +428,8 @@ class TestThumbnailInDedupHelpers:
|
||||
fps=25.0,
|
||||
)
|
||||
|
||||
assert result == 1
|
||||
# #1743:返回 dict(非批次 batch_similarity=None)
|
||||
assert result["video_count"] == 1
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
@@ -474,7 +476,7 @@ class TestThumbnailInDedupHelpers:
|
||||
fps=25.0,
|
||||
)
|
||||
|
||||
assert result == 1 # 不阻断
|
||||
assert result["video_count"] == 1 # 不阻断(#1743 dict 返回)
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
|
||||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""#1714 上传/转码链路(IngestJob + Asset)孤儿清理测试。
|
||||
|
||||
场景:worker 容器重启/进程 OOM 时,已 prefetch 的 transcode celery 消息丢失,
|
||||
ingest_job 永久卡 pending/processing、asset 永久卡 processing/uploading。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test_ingest_orphan.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from sqlalchemy import create_engine # noqa: E402
|
||||
from sqlalchemy.orm import sessionmaker # noqa: E402
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel, Base, IngestJobModel # noqa: E402
|
||||
from packages.application.ingest_orphan_cleanup import ( # noqa: E402
|
||||
cleanup_orphan_processing_assets,
|
||||
cleanup_stale_ingest_jobs,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
Session = sessionmaker(bind=engine)
|
||||
db = Session()
|
||||
yield db
|
||||
db.close()
|
||||
|
||||
|
||||
def _mk_job(session, *, status="processing", celery_task_id="cel-1", asset_id="a-1", minutes_ago=90):
|
||||
now = datetime.now(timezone.utc)
|
||||
job = IngestJobModel(
|
||||
id=f"job-{minutes_ago}-{status}-{celery_task_id}",
|
||||
project_id="p-1",
|
||||
library_id="lib-1",
|
||||
storage_key="uploads/x/IMG_2285.MOV",
|
||||
status=status,
|
||||
asset_id=asset_id,
|
||||
celery_task_id=celery_task_id,
|
||||
created_at=now - timedelta(minutes=minutes_ago),
|
||||
updated_at=now - timedelta(minutes=minutes_ago),
|
||||
)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
return job
|
||||
|
||||
|
||||
def _mk_asset(session, *, id="a-1", status="processing", minutes_ago=90, file_size=0):
|
||||
now = datetime.now(timezone.utc)
|
||||
asset = AssetModel(
|
||||
id=id,
|
||||
project_id="p-1",
|
||||
asset_library_id="lib-1",
|
||||
name="IMG_2285.MOV",
|
||||
file_type="video",
|
||||
file_size=file_size,
|
||||
file_url="https://example.com/x.mov",
|
||||
storage_key="uploads/x/IMG_2285.MOV",
|
||||
status=status,
|
||||
uploaded_by_user_id="u-1",
|
||||
created_at=now - timedelta(minutes=minutes_ago),
|
||||
updated_at=now - timedelta(minutes=minutes_ago),
|
||||
)
|
||||
session.add(asset)
|
||||
session.commit()
|
||||
return asset
|
||||
|
||||
|
||||
class TestCleanupStaleIngestJobs:
|
||||
def test_stale_processing_job_marked_failed_and_asset_to_error(self, session):
|
||||
"""processing 超 60 分钟 → job failed,关联 processing asset → error。"""
|
||||
_mk_asset(session, id="a-1", status="processing")
|
||||
_mk_job(session, status="processing", celery_task_id="cel-dead", asset_id="a-1", minutes_ago=90)
|
||||
|
||||
items, asset_ids = cleanup_stale_ingest_jobs(session, processing_timeout_minutes=60, pending_timeout_minutes=90)
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0] == ("job-90-processing-cel-dead", "cel-dead")
|
||||
assert asset_ids == ["a-1"]
|
||||
db_job = session.query(IngestJobModel).one()
|
||||
assert db_job.status == "failed"
|
||||
assert "中断" in db_job.error_message
|
||||
db_asset = session.query(AssetModel).one()
|
||||
assert db_asset.status == "error"
|
||||
|
||||
def test_stale_pending_job_marked_failed(self, session):
|
||||
"""pending 超 90 分钟(从未被消费)→ job failed。"""
|
||||
_mk_asset(session, id="a-2", status="uploading")
|
||||
_mk_job(session, status="pending", celery_task_id="", asset_id="a-2", minutes_ago=120)
|
||||
|
||||
items, asset_ids = cleanup_stale_ingest_jobs(session, processing_timeout_minutes=60, pending_timeout_minutes=90)
|
||||
|
||||
assert len(items) == 1
|
||||
assert items[0][1] == "" # 无 celery task id
|
||||
assert session.query(IngestJobModel).one().status == "failed"
|
||||
assert session.query(AssetModel).one().status == "error"
|
||||
|
||||
def test_recent_processing_job_not_touched(self, session):
|
||||
"""processing 仅 10 分钟(正常转码中)→ 不误杀。"""
|
||||
_mk_asset(session, id="a-3", status="processing", minutes_ago=10)
|
||||
_mk_job(session, status="processing", celery_task_id="cel-live", asset_id="a-3", minutes_ago=10)
|
||||
|
||||
items, asset_ids = cleanup_stale_ingest_jobs(session, processing_timeout_minutes=60, pending_timeout_minutes=90)
|
||||
|
||||
assert items == []
|
||||
assert asset_ids == []
|
||||
assert session.query(IngestJobModel).one().status == "processing"
|
||||
assert session.query(AssetModel).one().status == "processing"
|
||||
|
||||
def test_recent_pending_job_not_touched(self, session):
|
||||
"""pending 仅 30 分钟(队列积压排队中)→ 不误杀。"""
|
||||
_mk_job(session, status="pending", asset_id="", minutes_ago=30)
|
||||
|
||||
items, _ = cleanup_stale_ingest_jobs(session, processing_timeout_minutes=60, pending_timeout_minutes=90)
|
||||
|
||||
assert items == []
|
||||
assert session.query(IngestJobModel).one().status == "pending"
|
||||
|
||||
def test_terminal_job_not_touched(self, session):
|
||||
"""已 completed/failed 的 job 不动。"""
|
||||
_mk_job(session, status="completed", celery_task_id="", asset_id="", minutes_ago=999)
|
||||
_mk_job(session, status="failed", celery_task_id="", asset_id="", minutes_ago=999)
|
||||
|
||||
items, _ = cleanup_stale_ingest_jobs(session)
|
||||
|
||||
assert items == []
|
||||
statuses = sorted(j.status for j in session.query(IngestJobModel).all())
|
||||
assert statuses == ["completed", "failed"]
|
||||
|
||||
def test_ready_asset_not_demoted(self, session):
|
||||
"""关联 asset 已是 ready(转码其实成功了,仅 job 回写失败)→ 不降级为 error。"""
|
||||
_mk_asset(session, id="a-4", status="ready")
|
||||
_mk_job(session, status="processing", celery_task_id="cel-x", asset_id="a-4", minutes_ago=90)
|
||||
|
||||
_, asset_ids = cleanup_stale_ingest_jobs(session)
|
||||
|
||||
assert asset_ids == [] # ready 不动
|
||||
assert session.query(AssetModel).one().status == "ready"
|
||||
|
||||
|
||||
class TestCleanupOrphanProcessingAssets:
|
||||
def test_orphan_asset_without_job_marked_error(self, session):
|
||||
"""无 ingest_job 关联、created 超 120 分钟的 processing 占位 → error。"""
|
||||
_mk_asset(session, id="orphan-1", status="processing", minutes_ago=150)
|
||||
|
||||
ids = cleanup_orphan_processing_assets(session, timeout_minutes=120)
|
||||
|
||||
assert ids == ["orphan-1"]
|
||||
assert session.query(AssetModel).one().status == "error"
|
||||
|
||||
def test_asset_with_active_job_not_touched(self, session):
|
||||
"""有 processing job 关联的 asset 不由本函数处理(归 cleanup_stale_ingest_jobs)。"""
|
||||
_mk_asset(session, id="a-5", status="processing", minutes_ago=150)
|
||||
_mk_job(session, status="processing", asset_id="a-5", minutes_ago=150)
|
||||
|
||||
ids = cleanup_orphan_processing_assets(session, timeout_minutes=120)
|
||||
|
||||
assert ids == []
|
||||
assert session.query(AssetModel).one().status == "processing"
|
||||
|
||||
def test_recent_orphan_asset_not_touched(self, session):
|
||||
"""无 job 但才创建 30 分钟 → 可能 complete 刚建、job 派单中,不动。"""
|
||||
_mk_asset(session, id="orphan-2", status="processing", minutes_ago=30)
|
||||
|
||||
ids = cleanup_orphan_processing_assets(session, timeout_minutes=120)
|
||||
|
||||
assert ids == []
|
||||
assert session.query(AssetModel).one().status == "processing"
|
||||
|
||||
|
||||
class TestRecoverStuckIngestJobsOnStartup:
|
||||
def test_stuck_processing_job_requeued(self, session):
|
||||
"""processing 超 10 分钟 → 重置 pending 并重新 send_task,回写新 celery id。"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from packages.application.ingest_orphan_cleanup import recover_stuck_ingest_jobs_on_startup
|
||||
|
||||
job = _mk_job(session, status="processing", celery_task_id="old-cel-1", asset_id="a-1", minutes_ago=30)
|
||||
|
||||
sent = []
|
||||
|
||||
def fake_send_task(name, args=None, **kw):
|
||||
sent.append((name, args))
|
||||
return SimpleNamespace(id="new-cel-9")
|
||||
|
||||
updated_ids = []
|
||||
recovered = recover_stuck_ingest_jobs_on_startup(
|
||||
session,
|
||||
send_task=fake_send_task,
|
||||
update_celery_task_id=lambda jid, cid: updated_ids.append((jid, cid)),
|
||||
stuck_minutes=10,
|
||||
)
|
||||
|
||||
assert recovered == 1
|
||||
assert sent == [("worker.ingest_asset", [job.id])]
|
||||
refreshed = session.query(IngestJobModel).filter_by(id=job.id).one()
|
||||
assert refreshed.status == "pending"
|
||||
assert refreshed.celery_task_id == "new-cel-9"
|
||||
assert updated_ids == [(job.id, "new-cel-9")]
|
||||
|
||||
def test_recent_processing_job_not_touched(self, session):
|
||||
"""processing 仅 5 分钟(正常转码中/部署交接窗口)→ 不抢。"""
|
||||
from packages.application.ingest_orphan_cleanup import recover_stuck_ingest_jobs_on_startup
|
||||
|
||||
_mk_job(session, status="processing", celery_task_id="live", asset_id="", minutes_ago=5)
|
||||
|
||||
sent = []
|
||||
recovered = recover_stuck_ingest_jobs_on_startup(
|
||||
session,
|
||||
send_task=lambda *a, **k: sent.append(a),
|
||||
stuck_minutes=10,
|
||||
)
|
||||
|
||||
assert recovered == 0
|
||||
assert sent == []
|
||||
assert session.query(IngestJobModel).one().status == "processing"
|
||||
|
||||
def test_lock_not_acquired_skips(self, session):
|
||||
"""未抢到分布式锁(另一 worker 正在恢复)→ 跳过。"""
|
||||
from packages.application.ingest_orphan_cleanup import recover_stuck_ingest_jobs_on_startup
|
||||
|
||||
_mk_job(session, status="processing", celery_task_id="x", asset_id="", minutes_ago=30)
|
||||
|
||||
recovered = recover_stuck_ingest_jobs_on_startup(
|
||||
session,
|
||||
send_task=lambda *a, **k: None,
|
||||
lock_acquire=lambda: False,
|
||||
stuck_minutes=10,
|
||||
)
|
||||
|
||||
assert recovered == 0
|
||||
assert session.query(IngestJobModel).one().status == "processing"
|
||||
|
||||
def test_pending_and_terminal_not_requeued(self, session):
|
||||
"""pending/已终态 job 不在恢复范围。"""
|
||||
from packages.application.ingest_orphan_cleanup import recover_stuck_ingest_jobs_on_startup
|
||||
|
||||
_mk_job(session, status="pending", celery_task_id="", asset_id="", minutes_ago=60)
|
||||
_mk_job(session, status="failed", celery_task_id="", asset_id="", minutes_ago=60)
|
||||
|
||||
recovered = recover_stuck_ingest_jobs_on_startup(
|
||||
session,
|
||||
send_task=lambda *a, **k: None,
|
||||
stuck_minutes=10,
|
||||
)
|
||||
|
||||
assert recovered == 0
|
||||
statuses = sorted(j.status for j in session.query(IngestJobModel).all())
|
||||
assert statuses == ["failed", "pending"]
|
||||
@@ -0,0 +1,222 @@
|
||||
"""#1718:PATCH /auth/me 资料更新接口测试。
|
||||
|
||||
覆盖:
|
||||
- 正常更新昵称并落库
|
||||
- strip 生效(前后空白去除)
|
||||
- 纯空白/超长 -> 422(pydantic 校验)
|
||||
- 首次设置昵称 profile_completed False->True
|
||||
- 已完成用户重复提交幂等(仍 True)
|
||||
- 未登录由 get_current_user 依赖保证 401(框架行为,这里验证路由声明了该依赖)
|
||||
- 响应结构 {user: {...}} 含 wechat_bound/profile_completed 全字段
|
||||
- 微信新建用户 profile_completed 默认 False(wechat_sync _create_wechat_user)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.api.routes import auth as auth_route # noqa: E402
|
||||
|
||||
from packages.adapters.in_memory.user_repository import InMemoryUserRepository # noqa: E402
|
||||
from packages.domain.entities import User # noqa: E402
|
||||
|
||||
|
||||
def _auth_user(user):
|
||||
return SimpleNamespace(user=user, session_id="s-1", token_type="user_auth")
|
||||
|
||||
|
||||
def _make_user(**kw):
|
||||
defaults = dict(
|
||||
id="u-1",
|
||||
email="user@example.com",
|
||||
username="user",
|
||||
display_name="微信用户",
|
||||
password_hash="x",
|
||||
email_verified=True,
|
||||
profile_completed=False,
|
||||
)
|
||||
defaults.update(kw)
|
||||
return User(**defaults)
|
||||
|
||||
|
||||
# ---------- 请求体校验 ----------
|
||||
|
||||
|
||||
def test_display_name_strips_whitespace():
|
||||
req = auth_route.UpdateProfileRequest(display_name=" ying123 ")
|
||||
assert req.display_name == "ying123"
|
||||
|
||||
|
||||
def test_display_name_blank_rejected():
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
auth_route.UpdateProfileRequest(display_name=" ")
|
||||
assert "空白" in str(exc.value)
|
||||
|
||||
|
||||
def test_display_name_empty_rejected():
|
||||
with pytest.raises(ValidationError):
|
||||
auth_route.UpdateProfileRequest(display_name="")
|
||||
|
||||
|
||||
def test_display_name_too_long_rejected():
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
auth_route.UpdateProfileRequest(display_name="甲" * 21)
|
||||
assert "1-20" in str(exc.value)
|
||||
|
||||
|
||||
def test_display_name_max_length_accepted():
|
||||
req = auth_route.UpdateProfileRequest(display_name="甲" * 20)
|
||||
assert req.display_name == "甲" * 20
|
||||
|
||||
|
||||
# ---------- 路由逻辑 ----------
|
||||
|
||||
|
||||
def test_patch_me_updates_display_name_and_persists():
|
||||
user = _make_user()
|
||||
repo = InMemoryUserRepository()
|
||||
repo.save(user)
|
||||
|
||||
resp = asyncio.run(
|
||||
auth_route.update_current_user_profile(
|
||||
auth_route.UpdateProfileRequest(display_name=" ying123 "),
|
||||
current_user=_auth_user(user),
|
||||
user_repository=repo,
|
||||
)
|
||||
)
|
||||
assert resp.user.display_name == "ying123"
|
||||
assert resp.user.profile_completed is True
|
||||
assert resp.user.wechat_bound is False
|
||||
# 落库验证
|
||||
fresh = repo.find_by_id("u-1")
|
||||
assert fresh.display_name == "ying123"
|
||||
assert fresh.profile_completed is True
|
||||
|
||||
|
||||
def test_patch_me_first_time_sets_profile_completed_true():
|
||||
user = _make_user(profile_completed=False)
|
||||
repo = InMemoryUserRepository()
|
||||
repo.save(user)
|
||||
assert repo.find_by_id("u-1").profile_completed is False
|
||||
|
||||
asyncio.run(
|
||||
auth_route.update_current_user_profile(
|
||||
auth_route.UpdateProfileRequest(display_name="小虾"),
|
||||
current_user=_auth_user(user),
|
||||
user_repository=repo,
|
||||
)
|
||||
)
|
||||
assert repo.find_by_id("u-1").profile_completed is True
|
||||
|
||||
|
||||
def test_patch_me_idempotent_for_completed_user():
|
||||
user = _make_user(display_name="老名字", profile_completed=True)
|
||||
repo = InMemoryUserRepository()
|
||||
repo.save(user)
|
||||
|
||||
resp = asyncio.run(
|
||||
auth_route.update_current_user_profile(
|
||||
auth_route.UpdateProfileRequest(display_name="新名字"),
|
||||
current_user=_auth_user(user),
|
||||
user_repository=repo,
|
||||
)
|
||||
)
|
||||
assert resp.user.profile_completed is True
|
||||
assert resp.user.display_name == "新名字"
|
||||
# 再提交一次同样内容,不报错、状态稳定
|
||||
resp2 = asyncio.run(
|
||||
auth_route.update_current_user_profile(
|
||||
auth_route.UpdateProfileRequest(display_name="新名字"),
|
||||
current_user=_auth_user(repo.find_by_id("u-1")),
|
||||
user_repository=repo,
|
||||
)
|
||||
)
|
||||
assert resp2.user.profile_completed is True
|
||||
|
||||
|
||||
def test_patch_me_response_contains_all_me_fields():
|
||||
user = _make_user(wechat_openid="wx-1", phone="13800000000", phone_verified=True)
|
||||
repo = InMemoryUserRepository()
|
||||
repo.save(user)
|
||||
|
||||
resp = asyncio.run(
|
||||
auth_route.update_current_user_profile(
|
||||
auth_route.UpdateProfileRequest(display_name="昵称"),
|
||||
current_user=_auth_user(user),
|
||||
user_repository=repo,
|
||||
)
|
||||
)
|
||||
payload = resp.user.model_dump()
|
||||
for field in (
|
||||
"user_id",
|
||||
"email",
|
||||
"username",
|
||||
"display_name",
|
||||
"email_verified",
|
||||
"phone",
|
||||
"phone_verified",
|
||||
"binding_complete",
|
||||
"wechat_bound",
|
||||
"profile_completed",
|
||||
):
|
||||
assert field in payload, f"missing field {field}"
|
||||
assert payload["wechat_bound"] is True
|
||||
assert payload["phone"] == "13800000000"
|
||||
|
||||
|
||||
def test_get_me_includes_profile_completed_flag():
|
||||
# 未完成
|
||||
u = _make_user(profile_completed=False)
|
||||
resp = asyncio.run(auth_route.get_current_user_info(authenticated_user=_auth_user(u)))
|
||||
assert resp.profile_completed is False
|
||||
assert resp.wechat_bound is False
|
||||
|
||||
# 已完成 + 已绑微信
|
||||
u2 = _make_user(profile_completed=True, wechat_openid="wx-9")
|
||||
resp2 = asyncio.run(auth_route.get_current_user_info(authenticated_user=_auth_user(u2)))
|
||||
assert resp2.profile_completed is True
|
||||
assert resp2.wechat_bound is True
|
||||
|
||||
|
||||
def test_patch_me_requires_auth_dependency():
|
||||
# 路由签名必须依赖 get_current_user,未携带 token 时框架返回 401
|
||||
params = (
|
||||
auth_route.update_current_user_profile.__wrapped__
|
||||
if hasattr(auth_route.update_current_user_profile, "__wrapped__")
|
||||
else auth_route.update_current_user_profile
|
||||
)
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(params)
|
||||
dep = sig.parameters.get("current_user")
|
||||
assert dep is not None
|
||||
assert dep.default is not None and getattr(dep.default, "dependency", None) is auth_route.get_current_user
|
||||
|
||||
|
||||
def test_wechat_new_user_created_with_profile_completed_false():
|
||||
# 微信同步建号:新用户 profile_completed=False(需引导设置昵称)
|
||||
from packages.application.auth.wechat_sync_use_case import (
|
||||
WechatSyncRequest,
|
||||
WechatSyncUseCase,
|
||||
)
|
||||
|
||||
repo = InMemoryUserRepository()
|
||||
# session_store 用 mock,不依赖 redis
|
||||
use_case = WechatSyncUseCase(user_repository=repo, session_store=MagicMock(), jwt_secret_key="test-secret")
|
||||
resp, err = use_case.execute(WechatSyncRequest(openid="wx-new-openid", nickname="微信测试", source="web"))
|
||||
assert err is None
|
||||
user = repo.find_by_id(resp.user_id)
|
||||
assert user.profile_completed is False
|
||||
@@ -0,0 +1,471 @@
|
||||
"""#1714 prepare_direct_upload 去重 + 预建 asset 测试。
|
||||
|
||||
覆盖 4 类用例:
|
||||
- 第一次上传:prepare 返回 duplicated=false + asset_id 非空
|
||||
- 第二次同 hash:prepare 返回 duplicated=true, skip_transfer=true
|
||||
- 同 client_upload_id 重试:prepare 也直接跳过
|
||||
- file_hash 空:走老逻辑,duplicated=false,无 asset_id
|
||||
|
||||
以及:
|
||||
- pre-create 的 PROCESSING 占位不被"文件名兜底去重"误命中
|
||||
- _create_pending_asset find-or-create 复用现有记录
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from apps.api.app.api.routes import upload as upload_route # noqa: E402
|
||||
from packages.domain.entities import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, Project # noqa: E402
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeAssetRepo:
|
||||
"""内存 asset 仓储:实现 prepare/complete 去重需要的所有方法。"""
|
||||
|
||||
def __init__(self):
|
||||
self.assets = {} # id -> Asset
|
||||
self.saved = 0
|
||||
self.updated = 0
|
||||
|
||||
def create(self, asset):
|
||||
self.assets[asset.id] = asset
|
||||
self.saved += 1
|
||||
return asset
|
||||
|
||||
def update(self, asset):
|
||||
self.assets[asset.id] = asset
|
||||
self.updated += 1
|
||||
return asset
|
||||
|
||||
def find_by_id(self, asset_id):
|
||||
return self.assets.get(asset_id)
|
||||
|
||||
def find_by_library_and_file_hash(self, library_id, file_hash):
|
||||
if not file_hash:
|
||||
return None
|
||||
for a in self.assets.values():
|
||||
if a.library_id == library_id and a.file_hash == file_hash:
|
||||
return a
|
||||
return None
|
||||
|
||||
def find_by_library_and_client_upload_id(self, library_id, client_upload_id):
|
||||
if not client_upload_id:
|
||||
return None
|
||||
for a in self.assets.values():
|
||||
if a.library_id == library_id and a.client_upload_id == client_upload_id:
|
||||
return a
|
||||
return None
|
||||
|
||||
def find_recent_active_by_library_and_name(self, library_id, name, within_minutes=30, file_size=0):
|
||||
return None
|
||||
|
||||
|
||||
def _make_asset(**kw):
|
||||
defaults = dict(
|
||||
project_id="p-1",
|
||||
library_id="lib-1",
|
||||
name="existing.mp4",
|
||||
storage_key="uploads/old/existing.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.READY,
|
||||
file_hash="existinghash",
|
||||
)
|
||||
defaults.update(kw)
|
||||
return Asset(id=defaults.pop("id", "existing-asset"), **defaults)
|
||||
|
||||
|
||||
def _make_pending(**kw):
|
||||
defaults = dict(
|
||||
project_id="p-1",
|
||||
library_id="lib-1",
|
||||
name="test.mp4",
|
||||
storage_key="uploads/abc/test.mp4",
|
||||
mime_type="video/mp4",
|
||||
status=AssetStatus.PROCESSING,
|
||||
file_hash="abc123",
|
||||
)
|
||||
defaults.update(kw)
|
||||
return Asset(id=defaults.pop("id", "pending-asset"), **defaults)
|
||||
|
||||
|
||||
def _user():
|
||||
return SimpleNamespace(user=SimpleNamespace(id="user-1"), session_id="s", token_type="t")
|
||||
|
||||
|
||||
class _StubProjectRepo:
|
||||
def __init__(self, project):
|
||||
self._p = project
|
||||
|
||||
def get(self, pid):
|
||||
return self._p if self._p.id == pid else None
|
||||
|
||||
def find_by_id(self, pid):
|
||||
return self._p if self._p.id == pid else None
|
||||
|
||||
|
||||
class _StubLibraryRepo:
|
||||
def __init__(self, lib):
|
||||
self._lib = lib
|
||||
|
||||
def find_by_project(self, pid, kind=None):
|
||||
if self._lib.project_id == pid:
|
||||
return [self._lib]
|
||||
return []
|
||||
|
||||
|
||||
_FIXTURE_PROJECT = Project(id="p-1", owner_user_id="user-1", name="proj", description="")
|
||||
_FIXTURE_LIBRARY = AssetLibrary(
|
||||
id="lib-1", project_id="p-1", name="videos", kind=AssetLibraryKind.VIDEO, asset_count=0, total_size=0
|
||||
)
|
||||
|
||||
|
||||
def _storage():
|
||||
s = MagicMock()
|
||||
s.create_direct_upload_post.return_value = {
|
||||
"url": "https://bucket.oss.example.com",
|
||||
"method": "POST",
|
||||
"storage_key": "uploads/abc/test.mp4",
|
||||
"expires_at": "2026-01-01T00:00:00Z",
|
||||
"fields": {"key": "uploads/abc/test.mp4"},
|
||||
}
|
||||
return s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 场景 1:第一次上传(无 file_hash)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_first_upload_no_hash_returns_no_dedup():
|
||||
repo = _FakeAssetRepo()
|
||||
req = SimpleNamespace(
|
||||
project_id="p-1",
|
||||
library_id="lib-1",
|
||||
filename="test.mp4",
|
||||
content_type="video/mp4",
|
||||
file_size=1024,
|
||||
file_hash="",
|
||||
client_upload_id="",
|
||||
)
|
||||
resp = await upload_route.prepare_direct_upload(
|
||||
request=req,
|
||||
authenticated_user=_user(),
|
||||
project_repository=_StubProjectRepo(_FIXTURE_PROJECT),
|
||||
asset_library_repository=_StubLibraryRepo(_FIXTURE_LIBRARY),
|
||||
asset_repository=repo,
|
||||
storage_service=_storage(),
|
||||
)
|
||||
assert resp.duplicated is False
|
||||
assert resp.skip_transfer is False
|
||||
assert resp.asset_id == "" # file_hash 空,不预建
|
||||
assert repo.saved == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 场景 2:第一次上传带 file_hash → duplicated=false + asset_id 非空
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_first_upload_with_hash_creates_pending():
|
||||
repo = _FakeAssetRepo()
|
||||
req = SimpleNamespace(
|
||||
project_id="p-1",
|
||||
library_id="lib-1",
|
||||
filename="test.mp4",
|
||||
content_type="video/mp4",
|
||||
file_size=1024,
|
||||
file_hash="abc123",
|
||||
client_upload_id="",
|
||||
)
|
||||
resp = await upload_route.prepare_direct_upload(
|
||||
request=req,
|
||||
authenticated_user=_user(),
|
||||
project_repository=_StubProjectRepo(_FIXTURE_PROJECT),
|
||||
asset_library_repository=_StubLibraryRepo(_FIXTURE_LIBRARY),
|
||||
asset_repository=repo,
|
||||
storage_service=_storage(),
|
||||
)
|
||||
assert resp.duplicated is False
|
||||
assert resp.skip_transfer is False
|
||||
assert resp.asset_id != ""
|
||||
# 预建记录确实落库
|
||||
assert repo.saved == 1
|
||||
pending = repo.find_by_id(resp.asset_id)
|
||||
assert pending is not None
|
||||
assert pending.file_hash == "abc123"
|
||||
assert pending.status == AssetStatus.PROCESSING
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 场景 3:第二次同 hash → duplicated=true, skip_transfer=true
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_second_upload_same_hash_returns_duplicated():
|
||||
repo = _FakeAssetRepo()
|
||||
repo.create(_make_pending(file_hash="abc123", id="existing-asset"))
|
||||
req = SimpleNamespace(
|
||||
project_id="p-1",
|
||||
library_id="lib-1",
|
||||
filename="test.mp4",
|
||||
content_type="video/mp4",
|
||||
file_size=1024,
|
||||
file_hash="abc123",
|
||||
client_upload_id="",
|
||||
)
|
||||
resp = await upload_route.prepare_direct_upload(
|
||||
request=req,
|
||||
authenticated_user=_user(),
|
||||
project_repository=_StubProjectRepo(_FIXTURE_PROJECT),
|
||||
asset_library_repository=_StubLibraryRepo(_FIXTURE_LIBRARY),
|
||||
asset_repository=repo,
|
||||
storage_service=_storage(),
|
||||
)
|
||||
assert resp.duplicated is True
|
||||
assert resp.skip_transfer is True
|
||||
assert resp.asset_id == "existing-asset"
|
||||
assert resp.upload_url == "" # 未签名 OSS
|
||||
# 未新增记录
|
||||
assert repo.saved == 1 # 只有初始那条
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 场景 4:同 client_upload_id 重试 → 直接跳过
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_retry_same_client_upload_id_skips():
|
||||
repo = _FakeAssetRepo()
|
||||
repo.create(
|
||||
_make_pending(
|
||||
file_hash="abc123",
|
||||
client_upload_id="cuid-xyz",
|
||||
id="existing-asset",
|
||||
)
|
||||
)
|
||||
# 即使 file_hash 不同(理论上不会),client_upload_id 命中也直接跳过
|
||||
req = SimpleNamespace(
|
||||
project_id="p-1",
|
||||
library_id="lib-1",
|
||||
filename="test.mp4",
|
||||
content_type="video/mp4",
|
||||
file_size=1024,
|
||||
file_hash="different-hash",
|
||||
client_upload_id="cuid-xyz",
|
||||
)
|
||||
resp = await upload_route.prepare_direct_upload(
|
||||
request=req,
|
||||
authenticated_user=_user(),
|
||||
project_repository=_StubProjectRepo(_FIXTURE_PROJECT),
|
||||
asset_library_repository=_StubLibraryRepo(_FIXTURE_LIBRARY),
|
||||
asset_repository=repo,
|
||||
storage_service=_storage(),
|
||||
)
|
||||
assert resp.duplicated is True
|
||||
assert resp.skip_transfer is True
|
||||
assert resp.asset_id == "existing-asset"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 兜底:文件名兜底去重不误命中 PROCESSING 占位
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_filename_fallback_does_not_match_processing_pending():
|
||||
"""_find_duplicate_asset 按文件名兜底时,不能命中 pre-create 的 PROCESSING 记录。"""
|
||||
repo = _FakeAssetRepo()
|
||||
repo.create(_make_pending(id="p1"))
|
||||
result = upload_route._find_duplicate_asset(
|
||||
repo,
|
||||
library_id="lib-1",
|
||||
file_hash="", # 无 hash
|
||||
client_upload_id="", # 无 cuid
|
||||
filename="test.mp4", # 同名
|
||||
file_size=1024,
|
||||
)
|
||||
assert result is None # PROCESSING 占位不被兜底命中
|
||||
|
||||
|
||||
def test_filename_fallback_matches_stable_ready_record():
|
||||
"""READY 状态的已存在记录能被文件名兜底命中。"""
|
||||
repo = _FakeAssetRepo()
|
||||
repo.create(_make_asset(status=AssetStatus.READY, id="ready-asset"))
|
||||
# 伪造 find_recent_active_by_library_and_name 返回 READY 记录
|
||||
repo.find_recent_active_by_library_and_name = lambda **kw: repo.assets["ready-asset"]
|
||||
result = upload_route._find_duplicate_asset(
|
||||
repo,
|
||||
library_id="lib-1",
|
||||
file_hash="",
|
||||
client_upload_id="",
|
||||
filename="existing.mp4",
|
||||
file_size=1024,
|
||||
)
|
||||
assert result is not None
|
||||
assert result.id == "ready-asset"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _create_pending_asset find-or-create
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_pending_asset_reuses_existing_by_hash():
|
||||
"""_create_pending_asset:file_hash 命中现有 PROCESSING 记录则复用,不新建。"""
|
||||
repo = _FakeAssetRepo()
|
||||
repo.create(_make_pending(file_hash="abc123", client_upload_id="", id="p1"))
|
||||
# 复用
|
||||
result = upload_route._create_pending_asset(
|
||||
asset_repository=repo,
|
||||
project_id="p-1",
|
||||
library_id="lib-1",
|
||||
storage_key="uploads/new/test.mp4",
|
||||
filename="test.mp4",
|
||||
mime_type="video/mp4",
|
||||
user_id="user-1",
|
||||
file_hash="abc123",
|
||||
client_upload_id="cuid-new",
|
||||
)
|
||||
assert result.id == "p1"
|
||||
assert repo.saved == 1 # 没新增
|
||||
assert repo.updated >= 1 # 字段补齐触发 update
|
||||
assert result.client_upload_id == "cuid-new"
|
||||
|
||||
|
||||
def test_create_pending_asset_creates_when_no_match():
|
||||
"""无匹配时正常新建。"""
|
||||
repo = _FakeAssetRepo()
|
||||
result = upload_route._create_pending_asset(
|
||||
asset_repository=repo,
|
||||
project_id="p-1",
|
||||
library_id="lib-1",
|
||||
storage_key="uploads/new/test.mp4",
|
||||
filename="test.mp4",
|
||||
mime_type="video/mp4",
|
||||
user_id="user-1",
|
||||
file_hash="newhash",
|
||||
client_upload_id="newcuid",
|
||||
)
|
||||
assert result.id != ""
|
||||
assert result.file_hash == "newhash"
|
||||
assert result.client_upload_id == "newcuid"
|
||||
assert repo.saved == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 兜底去重:PROCESSING 占位 hash 不同时跳过
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_filename_fallback_skips_processing_with_different_hash():
|
||||
"""PROCESSING/UPLOADING 占位记录仅当 hash 一致(或占位无 hash)才命中;hash 不同跳过。"""
|
||||
repo = _FakeAssetRepo()
|
||||
repo.create(_make_pending(id="p1", file_hash="oldhash"))
|
||||
repo.find_recent_active_by_library_and_name = lambda **kw: repo.assets["p1"]
|
||||
result = upload_route._find_duplicate_asset(
|
||||
repo,
|
||||
library_id="lib-1",
|
||||
file_hash="differenthash", # 新上传内容不同
|
||||
client_upload_id="",
|
||||
filename="test.mp4",
|
||||
file_size=1024,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_filename_fallback_matches_processing_with_same_hash():
|
||||
"""PROCESSING 占位 hash 与请求一致时命中(重试场景)。"""
|
||||
repo = _FakeAssetRepo()
|
||||
repo.create(_make_pending(id="p1", file_hash="samehash"))
|
||||
repo.find_recent_active_by_library_and_name = lambda **kw: repo.assets["p1"]
|
||||
result = upload_route._find_duplicate_asset(
|
||||
repo,
|
||||
library_id="lib-1",
|
||||
file_hash="samehash",
|
||||
client_upload_id="",
|
||||
filename="test.mp4",
|
||||
file_size=1024,
|
||||
)
|
||||
assert result is not None
|
||||
assert result.id == "p1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# prepare 预建失败降级:不阻塞签名
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_pending_asset_create_failure_degrades_gracefully():
|
||||
"""预建 asset 抛异常时,prepare 仍正常返回签名(duplicated=False, asset_id 空)。"""
|
||||
|
||||
class _BrokenRepo(_FakeAssetRepo):
|
||||
def create(self, asset):
|
||||
raise RuntimeError("db down")
|
||||
|
||||
repo = _BrokenRepo()
|
||||
req = SimpleNamespace(
|
||||
project_id="p-1",
|
||||
library_id="lib-1",
|
||||
filename="test.mp4",
|
||||
content_type="video/mp4",
|
||||
file_size=1024,
|
||||
file_hash="abc123",
|
||||
client_upload_id="cuid-1",
|
||||
)
|
||||
resp = await upload_route.prepare_direct_upload(
|
||||
request=req,
|
||||
authenticated_user=_user(),
|
||||
project_repository=_StubProjectRepo(_FIXTURE_PROJECT),
|
||||
asset_library_repository=_StubLibraryRepo(_FIXTURE_LIBRARY),
|
||||
asset_repository=repo,
|
||||
storage_service=_storage(),
|
||||
)
|
||||
assert resp.duplicated is False
|
||||
assert resp.skip_transfer is False
|
||||
assert resp.asset_id == "" # 预建失败,降级无 asset_id
|
||||
assert resp.upload_url != "" # 签名仍正常返回
|
||||
|
||||
|
||||
def test_create_pending_asset_update_failure_swallowed():
|
||||
"""复用占位记录时字段补齐 update 抛异常被吞掉,不阻塞返回。"""
|
||||
|
||||
class _UpdateBrokenRepo(_FakeAssetRepo):
|
||||
def update(self, asset):
|
||||
raise RuntimeError("db down")
|
||||
|
||||
repo = _UpdateBrokenRepo()
|
||||
repo.create(_make_pending(file_hash="abc123", client_upload_id="", id="p1"))
|
||||
result = upload_route._create_pending_asset(
|
||||
asset_repository=repo,
|
||||
project_id="p-1",
|
||||
library_id="lib-1",
|
||||
storage_key="uploads/new/test.mp4",
|
||||
filename="test.mp4",
|
||||
mime_type="video/mp4",
|
||||
user_id="user-1",
|
||||
file_hash="abc123",
|
||||
client_upload_id="cuid-new",
|
||||
file_size=1024,
|
||||
)
|
||||
assert result.id == "p1" # 仍复用,不抛异常
|
||||
assert repo.saved == 1
|
||||
@@ -0,0 +1,206 @@
|
||||
"""#1743 EditPlanService.reselect_plan_for_variant 服务层测试。
|
||||
|
||||
与 clone_plan_for_variant(只重算起点、素材/顺序不变)不同,reselect 完整重跑
|
||||
单视频选片:素材池 shuffle + main 片段顺序洗牌 + 起点重选 + 批次 20% 重叠避让。
|
||||
|
||||
覆盖:
|
||||
- 独立 plan:新 plan_id 与源不同、命名带后缀、模板/config 复制
|
||||
- 新片段经 replace_all_clips_transactional 落库,素材/起点与源 plan 存在差异
|
||||
- 源 plan 区间作为批次避让初始对象;record_used_segments 随新片段写回
|
||||
- 源 plan 无片段 → ValueError(不创建同源变体)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO_ROOT / "apps" / "api"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent)) # tests/unit
|
||||
|
||||
from test_edit_plan_service import StubEditPlanClipRepository, StubEditPlanRepository, _make_service # noqa: E402
|
||||
|
||||
from packages.domain.edit_plan_clip import EditPlanClip # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def svc_with_source():
|
||||
"""带源 plan(4 个 main 片段,素材 a1/a2/a3/a4)+ 2 个批次候选素材的 service。"""
|
||||
svc = _make_service()
|
||||
svc._clip_repo.session = MagicMock()
|
||||
|
||||
source = svc.create_plan(template_id="tpl-001", name="9/6-草稿", total_duration=20.0, config={"title": "源配置"})
|
||||
for i, aid in enumerate(["a1", "a2", "a3", "a4"]):
|
||||
clip = EditPlanClip.create(
|
||||
plan_id=source.id,
|
||||
clip_type="main",
|
||||
order=i,
|
||||
asset_id=aid,
|
||||
start_time=float(i * 5),
|
||||
duration=5.0,
|
||||
text_content=f"文案{i}",
|
||||
)
|
||||
svc._clip_repo.create(clip)
|
||||
return svc, source
|
||||
|
||||
|
||||
def _patch_deps(svc, durations):
|
||||
"""统一 patch reselect 的 DB/素材/历史区间依赖。返回 (patches, mock_replace)。"""
|
||||
asset_models = []
|
||||
for aid, dur in durations.items():
|
||||
m = MagicMock(id=aid)
|
||||
m.duration = dur
|
||||
m.metadata = None # extract_scene_points_from_metadata(None) → 无场景点
|
||||
asset_models.append(m)
|
||||
|
||||
mock_replace = patch.object(svc, "replace_all_clips_transactional", return_value=4)
|
||||
patches = [
|
||||
patch("app.services.edit_plan_service.get_used_segments", return_value={}),
|
||||
patch("app.services.edit_plan_service.record_used_segments", return_value=None),
|
||||
patch("packages.domain.plan_generator_utils.extract_scene_points_from_metadata", return_value=[]),
|
||||
patch("packages.adapters.sqlalchemy_impl.models.AssetModel", create=True),
|
||||
mock_replace,
|
||||
]
|
||||
started = []
|
||||
for p in patches:
|
||||
started.append(p.start())
|
||||
# started[-1] 是 replace_all 的 MagicMock
|
||||
mock_replace_obj = started[-1]
|
||||
# db.query(AssetModel).filter(...).all() → 带 duration 的素材 mock
|
||||
svc._clip_repo.session.query.return_value.filter.return_value.all.return_value = asset_models
|
||||
return patches, mock_replace_obj
|
||||
|
||||
|
||||
class TestReselectPlanForVariant:
|
||||
def test_creates_independent_plan_with_different_clips(self, svc_with_source):
|
||||
"""reselect 产出新 plan(id/名称不同),片段素材或起点与源 plan 存在差异。"""
|
||||
import random
|
||||
|
||||
svc, source = svc_with_source
|
||||
durations = {"a1": 300.0, "a2": 300.0, "a3": 300.0, "a4": 300.0, "a5": 300.0, "a6": 300.0}
|
||||
patches, mock_replace = _patch_deps(svc, durations)
|
||||
try:
|
||||
new_plan = svc.reselect_plan_for_variant(
|
||||
source.id,
|
||||
["a5", "a6"], # 批次素材并入素材池
|
||||
created_by_user_id="u-1",
|
||||
name_suffix="批量2",
|
||||
rng=random.Random(42),
|
||||
)
|
||||
finally:
|
||||
for p in reversed(patches):
|
||||
p.stop()
|
||||
|
||||
# 新 plan 独立、归属/模板/config 复制
|
||||
assert new_plan.id != source.id
|
||||
assert "批量2" in new_plan.name
|
||||
assert new_plan.template_id == "tpl-001"
|
||||
assert new_plan.config == {"title": "源配置"}
|
||||
assert new_plan.created_by_user_id == "u-1"
|
||||
|
||||
# 落库片段数 == 源片段数,且 order 对齐
|
||||
clips_data = mock_replace.call_args.args[1]
|
||||
assert len(clips_data) == 4
|
||||
assert [c["order"] for c in clips_data] == [0, 1, 2, 3]
|
||||
|
||||
# 与源 plan 对比:素材序列或起点必须存在差异(降重核心——不是克隆)
|
||||
source_pairs = [(c.asset_id, round(float(c.start_time), 2)) for c in svc._clip_repo.list_by_plan(source.id)]
|
||||
new_pairs = [(c["asset_id"], round(float(c["start_time"]), 2)) for c in clips_data]
|
||||
assert new_pairs != source_pairs, f"reselect 片段应与源 plan 不同,实际相同: {new_pairs}"
|
||||
# 素材全部来自素材池(源 a1-a4 ∪ 批次 a5-a6)
|
||||
for c in clips_data:
|
||||
assert c["asset_id"] in durations
|
||||
|
||||
def test_record_used_segments_called_per_clip(self, svc_with_source):
|
||||
"""每个新片段区间调用 record_used_segments 写回(跨变体/跨任务避让链路)。"""
|
||||
import random
|
||||
|
||||
svc, source = svc_with_source
|
||||
durations = {"a1": 300.0, "a2": 300.0, "a3": 300.0, "a4": 300.0, "a5": 300.0}
|
||||
|
||||
asset_models = []
|
||||
for aid in durations:
|
||||
m = MagicMock(id=aid)
|
||||
m.duration = durations[aid]
|
||||
m.metadata = None
|
||||
asset_models.append(m)
|
||||
svc._clip_repo.session.query.return_value.filter.return_value.all.return_value = asset_models
|
||||
|
||||
started = [
|
||||
patch("app.services.edit_plan_service.get_used_segments", return_value={}).start(),
|
||||
patch("packages.domain.plan_generator_utils.extract_scene_points_from_metadata", return_value=[]).start(),
|
||||
patch("packages.adapters.sqlalchemy_impl.models.AssetModel", create=True).start(),
|
||||
patch.object(svc, "replace_all_clips_transactional", return_value=4).start(),
|
||||
]
|
||||
mock_record = patch("app.services.edit_plan_service.record_used_segments", return_value=None).start()
|
||||
try:
|
||||
svc.reselect_plan_for_variant(
|
||||
source.id, ["a5"], created_by_user_id="u-1", name_suffix="批量2", rng=random.Random(5)
|
||||
)
|
||||
finally:
|
||||
patch.stopall()
|
||||
|
||||
assert mock_record.call_count == 4, "4 个片段应各写一次 used_segment"
|
||||
for call in mock_record.call_args_list:
|
||||
args = call.args
|
||||
assert args[1] in durations, f"asset_id {args[1]} 不在素材池" # asset_id
|
||||
assert args[3] > args[2], "区间 end 应大于 start" # end > start
|
||||
assert args[4], "new plan_id 应非空"
|
||||
|
||||
def test_source_clips_seed_batch_avoidance(self, svc_with_source):
|
||||
"""源 plan 片段区间进入批次避让集:与源完全同区间的起点重叠率应超限被避开。"""
|
||||
import random
|
||||
|
||||
svc, source = svc_with_source
|
||||
# 素材池只有源素材(极端小池),时长充足
|
||||
durations = {"a1": 600.0, "a2": 600.0, "a3": 600.0, "a4": 600.0}
|
||||
patches, mock_replace = _patch_deps(svc, durations)
|
||||
try:
|
||||
new_plan = svc.reselect_plan_for_variant(
|
||||
source.id, [], created_by_user_id="u-1", name_suffix="批量2", rng=random.Random(99)
|
||||
)
|
||||
finally:
|
||||
for p in reversed(patches):
|
||||
p.stop()
|
||||
|
||||
clips_data = mock_replace.call_args.args[1]
|
||||
source_clips = svc._clip_repo.list_by_plan(source.id)
|
||||
source_by_asset = {}
|
||||
for c in source_clips:
|
||||
source_by_asset.setdefault(c.asset_id, []).append(
|
||||
(float(c.start_time), float(c.start_time) + float(c.duration))
|
||||
)
|
||||
|
||||
# 同素材新片段与源区间的重叠占比均 ≤20%
|
||||
from packages.domain.variant_plan_selector import _clip_overlap_ratio
|
||||
|
||||
for c in clips_data:
|
||||
ratio = _clip_overlap_ratio(c["asset_id"], float(c["start_time"]), float(c["duration"]), source_by_asset)
|
||||
assert (
|
||||
ratio <= 0.20 + 1e-6
|
||||
), f"变体片段与源 plan 同素材区间重叠超限: asset={c['asset_id']} ratio={ratio:.2%}"
|
||||
assert new_plan.id != source.id
|
||||
|
||||
def test_source_plan_without_clips_raises(self):
|
||||
"""源 plan 无片段 → ValueError(明确报错,不产出同源变体)。"""
|
||||
svc = _make_service()
|
||||
svc._clip_repo.session = MagicMock()
|
||||
empty = svc.create_plan(template_id="tpl-x", name="空计划")
|
||||
|
||||
with pytest.raises(ValueError, match="源 plan 无片段"):
|
||||
svc.reselect_plan_for_variant(empty.id, ["a1"], created_by_user_id="u-1", name_suffix="变体")
|
||||
|
||||
def test_missing_source_plan_raises(self):
|
||||
"""源 plan 不存在 → get_plan_or_raise 抛错。"""
|
||||
svc = _make_service()
|
||||
svc._clip_repo.session = MagicMock()
|
||||
with pytest.raises((ValueError, KeyError, LookupError)): # get_plan_or_raise 抛错
|
||||
svc.reselect_plan_for_variant("not-exist-plan", ["a1"], created_by_user_id="u-1", name_suffix="变体")
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for packages/domain/smart_match.py — 统一智能选素材算法。"""
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
@@ -7,6 +8,7 @@ from typing import Any
|
||||
import pytest
|
||||
|
||||
from packages.domain.smart_match import (
|
||||
SCORE_RANDOM_NOISE_MAX,
|
||||
SmartMatchResult,
|
||||
_diversity_select,
|
||||
_duration_bucket,
|
||||
@@ -46,6 +48,22 @@ class FakeAsset:
|
||||
NOW = datetime(2026, 8, 5, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class _ZeroNoiseRandom(random.Random):
|
||||
"""零噪声随机源:uniform(0, NOISE_MAX) 恒返回 0,使「按评分降序」类断言确定可复现。
|
||||
|
||||
smart_select_assets 生产环境注入随机噪声(同分素材每次选出不同组合,#1743);
|
||||
验证纯评分排序的单测用本随机源消除排序随机性。
|
||||
"""
|
||||
|
||||
def uniform(self, a, b):
|
||||
if a == 0.0 and b == SCORE_RANDOM_NOISE_MAX:
|
||||
return 0.0
|
||||
return super().uniform(a, b)
|
||||
|
||||
|
||||
_ZERO_NOISE = _ZeroNoiseRandom(0)
|
||||
|
||||
|
||||
# ── score_asset tests ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -185,7 +203,7 @@ class TestSmartSelectAssets:
|
||||
FakeAsset(id="high", quality_score=95, duration=15),
|
||||
FakeAsset(id="mid", quality_score=60, duration=15),
|
||||
]
|
||||
results = smart_select_assets(assets)
|
||||
results = smart_select_assets(assets, rng=_ZERO_NOISE)
|
||||
scores = [r.score for r in results]
|
||||
assert scores == sorted(scores, reverse=True)
|
||||
assert results[0].asset.id == "high"
|
||||
@@ -234,7 +252,7 @@ class TestSmartSelectAssets:
|
||||
FakeAsset(id="img1", mime_type="image/jpeg", quality_score=90, duration=None),
|
||||
FakeAsset(id="img2", mime_type="image/png", quality_score=70, duration=None),
|
||||
]
|
||||
results = smart_select_assets(assets, kind="image")
|
||||
results = smart_select_assets(assets, kind="image", rng=_ZERO_NOISE)
|
||||
assert len(results) == 2
|
||||
assert results[0].asset.id == "img1"
|
||||
|
||||
|
||||
@@ -155,7 +155,9 @@ class TestSmartMatchAvailabilityFallback:
|
||||
project = Project(id="proj-1", name="Test", owner_user_id="user-1")
|
||||
assets = [
|
||||
_video_asset("top-exhausted.mp4", quality=100, used_ranges=_exhausted_ranges(15)),
|
||||
_video_asset("second-fresh.mp4", quality=40, used_ranges=None),
|
||||
# second 质量分显著高于 third(质量项差 (90-30)*0.4=24 > 噪声上限 20),
|
||||
# 排除耗尽素材后 second 稳定排首位回补(噪声不影响大分差排名)
|
||||
_video_asset("second-fresh.mp4", quality=90, used_ranges=None),
|
||||
_video_asset("third-fresh.mp4", quality=30, used_ranges=None),
|
||||
]
|
||||
repo = _StubAssetRepo(assets)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
@@ -41,6 +42,18 @@ class FakeAsset:
|
||||
file_type: str = "video"
|
||||
|
||||
|
||||
class _ZeroNoiseRandom(random.Random):
|
||||
"""零噪声随机源:uniform(0, NOISE_MAX) 恒返回 0,消除排序随机性;
|
||||
其他随机调用(非噪声区间)保持正常随机行为。"""
|
||||
|
||||
def uniform(self, a, b):
|
||||
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX
|
||||
|
||||
if a == 0.0 and b == SCORE_RANDOM_NOISE_MAX:
|
||||
return 0.0
|
||||
return super().uniform(a, b)
|
||||
|
||||
|
||||
def _asset_with_use_count(asset_id: str, use_count: int) -> FakeAsset:
|
||||
"""创建指定使用次数的素材,其他维度保持一致。"""
|
||||
return FakeAsset(
|
||||
@@ -112,25 +125,25 @@ class TestSmartSelectAssetsOrdering:
|
||||
"""验证 smart_select_assets 返回结果按评分降序。"""
|
||||
|
||||
def test_less_used_assets_ranked_higher(self):
|
||||
"""使用次数少的素材在结果中排名更高。"""
|
||||
"""使用次数少的素材在结果中排名更高(注入零噪声 rng 验证纯评分排序)。"""
|
||||
assets = [
|
||||
_asset_with_use_count("heavily_used", 10),
|
||||
_asset_with_use_count("never_used", 0),
|
||||
_asset_with_use_count("lightly_used", 2),
|
||||
]
|
||||
results = smart_select_assets(assets)
|
||||
results = smart_select_assets(assets, rng=_ZeroNoiseRandom())
|
||||
ids = [r.asset.id for r in results]
|
||||
# never_used 排第一,heavily_used 排最后
|
||||
assert ids[0] == "never_used"
|
||||
assert ids[-1] == "heavily_used"
|
||||
|
||||
def test_same_quality_different_use_count(self):
|
||||
"""质量相同时,使用次数少的排名更高。"""
|
||||
"""质量相同时,使用次数少的排名更高(注入零噪声 rng)。"""
|
||||
assets = [
|
||||
_asset_with_use_count("used_5", 5),
|
||||
_asset_with_use_count("used_0", 0),
|
||||
]
|
||||
results = smart_select_assets(assets)
|
||||
results = smart_select_assets(assets, rng=_ZeroNoiseRandom())
|
||||
assert results[0].asset.id == "used_0"
|
||||
assert results[1].asset.id == "used_5"
|
||||
|
||||
@@ -158,22 +171,47 @@ def _make_auth_user():
|
||||
return auth
|
||||
|
||||
|
||||
def _make_zero_noise_patcher(module):
|
||||
"""构造 patch(module.random.uniform):噪声调用(上界=SCORE_RANDOM_NOISE_MAX)返回 0。
|
||||
class _ZeroNoiseCtx:
|
||||
"""同时 patch module.random.uniform(排序噪声归零)与 module.random.shuffle
|
||||
(排序后的素材洗牌保持原序),使「按评分排序」类断言确定可复现。
|
||||
|
||||
其他 uniform 调用(如片段时长随机)委托给一个独立的 Random 实例,
|
||||
避免递归回已 patch 的全局函数。
|
||||
生产环境排序噪声与 shuffle 都是降重随机的一部分;测试需要验证纯评分
|
||||
排序时用本上下文消除随机性。其他 uniform 调用(片段时长随机等)委托给
|
||||
独立 Random 实例,避免递归回已 patch 的全局函数。
|
||||
"""
|
||||
import random as _stdlib_random
|
||||
|
||||
_fallback = _stdlib_random.Random()
|
||||
def __init__(self, module):
|
||||
import random as _stdlib_random
|
||||
|
||||
def _fake_uniform(a, b):
|
||||
if b == SCORE_RANDOM_NOISE_MAX:
|
||||
return 0.0
|
||||
return _fallback.uniform(a, b)
|
||||
self._module = module
|
||||
self._fallback = _stdlib_random.Random()
|
||||
self._patches = []
|
||||
|
||||
return patch.object(module.random, "uniform", _fake_uniform)
|
||||
def __enter__(self):
|
||||
import random as _stdlib_random
|
||||
|
||||
def _fake_uniform(a, b):
|
||||
if b == SCORE_RANDOM_NOISE_MAX:
|
||||
return 0.0
|
||||
return self._fallback.uniform(a, b)
|
||||
|
||||
def _no_shuffle(seq):
|
||||
return None # 保持原序,不洗牌
|
||||
|
||||
self._patches = [
|
||||
patch.object(self._module.random, "uniform", _fake_uniform).start(),
|
||||
patch.object(self._module.random, "shuffle", _no_shuffle).start(),
|
||||
]
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
patch.stopall()
|
||||
return False
|
||||
|
||||
|
||||
def _make_zero_noise_patcher(module):
|
||||
"""构造零噪声上下文(uniform 噪声归零 + shuffle 保持原序)。"""
|
||||
return _ZeroNoiseCtx(module)
|
||||
|
||||
|
||||
def _patch_zero_noise_clips():
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""#1743 smart-match 排序随机噪声测试。
|
||||
|
||||
smart_select_assets 排序注入 0~SCORE_RANDOM_NOISE_MAX 随机噪声后:
|
||||
- 同分/近分素材每次调用选出的组合与顺序不同(修复"每次只选同样几个素材")
|
||||
- 分差 > 噪声上限的高质量素材保持稳定优先级
|
||||
- r.score 始终为无噪声原始分;噪声只影响排序
|
||||
- 同一次调用内排序与多样性分桶使用一致噪声(结果稳定可复现)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
for sub in ("packages",):
|
||||
p = str(REPO_ROOT / sub)
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
from packages.domain.smart_match import ( # noqa: E402
|
||||
SCORE_RANDOM_NOISE_MAX,
|
||||
score_asset,
|
||||
smart_select_assets,
|
||||
)
|
||||
|
||||
NOW = datetime(2026, 9, 6, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeAsset:
|
||||
id: str
|
||||
duration: float = 15.0
|
||||
quality_score: float | None = None # None → 按 50 计,模拟 staging 真实情况
|
||||
status: str = "ready"
|
||||
metadata: dict = field(default_factory=dict)
|
||||
created_at: datetime = NOW
|
||||
|
||||
@property
|
||||
def file_type(self) -> str:
|
||||
return "video"
|
||||
|
||||
|
||||
def _make_tied_assets(n: int) -> list[FakeAsset]:
|
||||
"""构造 n 个综合得分完全相同的素材(quality NULL 按 50 + 时长 15s 满分 + 同创建时间)。"""
|
||||
return [FakeAsset(id=f"a{i}", duration=15.0, quality_score=None) for i in range(n)]
|
||||
|
||||
|
||||
class TestScoreNoiseInjection:
|
||||
def test_tied_assets_same_raw_score(self):
|
||||
"""前置校验:同分素材原始得分确实一致。"""
|
||||
assets = _make_tied_assets(6)
|
||||
scores = {score_asset(a, now=NOW)[0] for a in assets}
|
||||
assert len(scores) == 1, f"测试前提不成立:同分素材得分不一致 {scores}"
|
||||
|
||||
def test_tied_assets_order_varies_across_calls(self):
|
||||
"""同分素材:不同随机种子选出的顺序/组合不同(核心修复点)。"""
|
||||
orderings = set()
|
||||
for seed in range(8):
|
||||
results = smart_select_assets(_make_tied_assets(8), rng=random.Random(seed))
|
||||
orderings.add(tuple(r.asset.id for r in results))
|
||||
# 8 个不同种子应产生多种不同排序(若零随机噪声则只有 1 种)
|
||||
assert len(orderings) >= 4, f"同分素材排序几乎不变: {len(orderings)} 种"
|
||||
|
||||
def test_tied_assets_top_n_varies_with_limit(self):
|
||||
"""同分素材 + limit 截断:不同种子选出的 Top-N 组合不同。"""
|
||||
top_sets = set()
|
||||
for seed in range(10):
|
||||
results = smart_select_assets(_make_tied_assets(10), limit=3, rng=random.Random(seed))
|
||||
top_sets.add(frozenset(r.asset.id for r in results))
|
||||
assert len(top_sets) >= 4, f"Top-N 组合几乎不变: {len(top_sets)} 种"
|
||||
|
||||
def test_large_score_gap_keeps_priority(self):
|
||||
"""分差 > 噪声上限(20)时:低质素材即使噪声拉满也排不到高质素材前面。"""
|
||||
# 高质:quality=100 → quality_component=40;低质:quality=0 → 0,仅质量项就差 40 分
|
||||
high = [FakeAsset(id="high", quality_score=100, duration=15.0)]
|
||||
low = [FakeAsset(id=f"low{i}", quality_score=0, duration=15.0) for i in range(6)]
|
||||
for seed in range(20):
|
||||
results = smart_select_assets(high + low, limit=3, rng=random.Random(seed))
|
||||
assert results[0].asset.id == "high", f"seed={seed} 低质素材靠噪声排到首位"
|
||||
|
||||
def test_score_field_is_raw_without_noise(self):
|
||||
"""r.score 始终是无噪声原始分(噪声只影响排序,不污染返回分值)。"""
|
||||
assets = _make_tied_assets(5)
|
||||
raw_scores = {score_asset(a, now=NOW)[0] for a in assets}
|
||||
results = smart_select_assets(assets, rng=random.Random(42))
|
||||
for r in results:
|
||||
assert r.score in raw_scores
|
||||
|
||||
def test_rng_deterministic_same_seed(self):
|
||||
"""同一种子多次调用结果完全一致(可复现,测试可依赖)。"""
|
||||
run = lambda: tuple( # noqa: E731
|
||||
r.asset.id for r in smart_select_assets(_make_tied_assets(8), rng=random.Random(123))
|
||||
)
|
||||
assert run() == run()
|
||||
|
||||
def test_diversity_bucket_respects_noise(self):
|
||||
"""多样性分桶路径(候选数 > limit):同分素材跨种子入选组合不同。"""
|
||||
# 构造短/中/长三档同分素材各 4 个,limit=6 触发分桶轮询
|
||||
assets = []
|
||||
for i in range(4):
|
||||
assets.append(FakeAsset(id=f"short{i}", duration=6.0))
|
||||
for i in range(4):
|
||||
assets.append(FakeAsset(id=f"med{i}", duration=15.0))
|
||||
for i in range(4):
|
||||
assets.append(FakeAsset(id=f"long{i}", duration=45.0))
|
||||
# 同档内时长接近 → 得分接近
|
||||
combos = set()
|
||||
for seed in range(10):
|
||||
results = smart_select_assets(assets, limit=6, rng=random.Random(seed))
|
||||
combos.add(frozenset(r.asset.id for r in results))
|
||||
assert len(combos) >= 3, f"分桶选取组合几乎不变: {len(combos)} 种"
|
||||
|
||||
def test_noise_constant_matches_from_assets(self):
|
||||
"""噪声上限与 from-assets 片段分配的 SCORE_RANDOM_NOISE_MAX 同源(20 分)。"""
|
||||
assert SCORE_RANDOM_NOISE_MAX == 20.0
|
||||
|
||||
def test_returns_all_when_no_limit(self):
|
||||
"""无 limit 时返回全部候选(噪声只改顺序,不丢素材)。"""
|
||||
assets = _make_tied_assets(7)
|
||||
results = smart_select_assets(assets, rng=random.Random(1))
|
||||
assert len(results) == 7
|
||||
assert {r.asset.id for r in results} == {a.id for a in assets}
|
||||
|
||||
def test_non_ready_assets_excluded_before_noise(self):
|
||||
"""非 ready 素材不参与排序(噪声不影响状态过滤)。"""
|
||||
assets = _make_tied_assets(4)
|
||||
assets[0].status = "processing"
|
||||
results = smart_select_assets(assets, rng=random.Random(1))
|
||||
assert all(r.asset.status == "ready" for r in results)
|
||||
assert len(results) == 3
|
||||
@@ -51,6 +51,7 @@ def test_generation_cleans_up_temp_dir():
|
||||
assert "rmtree(render_temp_dir" in source, "generation.py 应清理 render_temp_dir"
|
||||
|
||||
# 验证清理发生在上传之后(通过查找顺序)
|
||||
upload_pos = source.find("_upload_and_record")
|
||||
# #1743:_upload_and_record 拆分为 _upload_rendered_video(仅 OSS 上传)
|
||||
upload_pos = source.find("_upload_rendered_video")
|
||||
cleanup_pos = source.find("rmtree(render_temp_dir")
|
||||
assert upload_pos > 0 and cleanup_pos > upload_pos, "清理临时目录应在 _upload_and_record 之后执行"
|
||||
assert upload_pos > 0 and cleanup_pos > upload_pos, "清理临时目录应在 _upload_rendered_video 之后执行"
|
||||
|
||||
@@ -73,6 +73,9 @@ class StubAssetRepository:
|
||||
def find_recent_active_by_library_and_name(
|
||||
self, library_id: str, name: str, within_minutes: int = 30, file_size: int = 0
|
||||
) -> Asset | None:
|
||||
# 严格模式(#1714):大小未知(0)直接不命中,宁可漏判不可误杀
|
||||
if not file_size or file_size <= 0:
|
||||
return None
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=within_minutes)
|
||||
candidates = [
|
||||
a
|
||||
@@ -81,7 +84,7 @@ class StubAssetRepository:
|
||||
and a.name == name
|
||||
and a.status in (AssetStatus.UPLOADING, AssetStatus.PROCESSING)
|
||||
and a.created_at >= cutoff
|
||||
and (not file_size or a.file_size == file_size)
|
||||
and a.file_size == file_size
|
||||
]
|
||||
return max(candidates, key=lambda a: a.created_at) if candidates else None
|
||||
|
||||
@@ -234,14 +237,21 @@ class TestDirectCompleteIdempotency:
|
||||
不应再建第二条。
|
||||
"""
|
||||
client, asset_repo, ingest_repo, _ = _client()
|
||||
# 第一次 complete(旧客户端无 token/hash)
|
||||
r1 = client.post("/api/v1/direct/complete", json=COMPLETE_BODY)
|
||||
# 第一次 complete(旧客户端无 token/hash,但 file_size 可知)
|
||||
r1 = client.post(
|
||||
"/api/v1/direct/complete",
|
||||
json={**COMPLETE_BODY, "file_size": 5_000_000},
|
||||
)
|
||||
assert r1.json()["duplicated"] is False
|
||||
# 重试:重新 prepare 产生新 storage_key(仅 uuid 目录不同,文件名一致——
|
||||
# 前端重试传的是同一个 File),且近期
|
||||
# 前端重试传的是同一个 File),且近期;同大小才允许兜底命中
|
||||
r2 = client.post(
|
||||
"/api/v1/direct/complete",
|
||||
json={**COMPLETE_BODY, "storage_key": "uploads/retry/IMG_2282.MOV", "file_size": 0},
|
||||
json={
|
||||
**COMPLETE_BODY,
|
||||
"storage_key": "uploads/retry/IMG_2282.MOV",
|
||||
"file_size": 5_000_000,
|
||||
},
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["duplicated"] is True
|
||||
@@ -249,6 +259,80 @@ class TestDirectCompleteIdempotency:
|
||||
assert len(asset_repo.created) == 1
|
||||
assert ingest_repo.created_count == 1
|
||||
|
||||
def test_fallback_dedup_skipped_when_file_size_unknown(self):
|
||||
"""file_size=0(未知)时不允许仅凭同名 + processing 判重,直接放行(#1714)。
|
||||
|
||||
根因场景:complete 没传 file_size,30 分钟内同名占位(如 iPhone 的
|
||||
IMG_2285.MOV)会把内容/大小全新的视频误判为重复跳过。
|
||||
"""
|
||||
client, asset_repo, _ingest_repo, _ = _client()
|
||||
r1 = client.post(
|
||||
"/api/v1/direct/complete",
|
||||
json={**COMPLETE_BODY, "file_size": 0},
|
||||
)
|
||||
assert r1.json()["duplicated"] is False
|
||||
# 第二个全新视频:同名(IMG_2285.MOV)、无 hash/token、file_size 仍未知
|
||||
r2 = client.post(
|
||||
"/api/v1/direct/complete",
|
||||
json={**COMPLETE_BODY, "storage_key": "uploads/retry2/IMG_2282.MOV", "file_size": 0},
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["duplicated"] is False # 不能误杀
|
||||
assert len(asset_repo.created) == 2 # 两条记录,放行新上传
|
||||
|
||||
def test_fallback_dedup_skipped_when_same_name_but_different_size(self):
|
||||
"""同名但 file_size 不同 → 不判重,正常建记录(#1714)。"""
|
||||
client, asset_repo, _ingest_repo, _ = _client()
|
||||
r1 = client.post(
|
||||
"/api/v1/direct/complete",
|
||||
json={**COMPLETE_BODY, "file_size": 5_000_000},
|
||||
)
|
||||
assert r1.json()["duplicated"] is False
|
||||
r2 = client.post(
|
||||
"/api/v1/direct/complete",
|
||||
json={
|
||||
**COMPLETE_BODY,
|
||||
"storage_key": "uploads/retry3/IMG_2282.MOV",
|
||||
"file_size": 9_999_999, # 同名但大小完全不同的新视频
|
||||
},
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["duplicated"] is False
|
||||
assert len(asset_repo.created) == 2
|
||||
|
||||
def test_fallback_dedup_skipped_when_hash_present_even_if_name_size_match(self):
|
||||
"""file_hash 非空且 hash 未命中时,不允许退回同名兜底(#1714)。
|
||||
|
||||
hash 已能代表内容:同名同大小但 hash 不同是真实的新内容,必须放行。
|
||||
"""
|
||||
client, asset_repo, _ingest_repo, _ = _client()
|
||||
# 第一次:某 hash 的视频
|
||||
r1 = client.post(
|
||||
"/api/v1/direct/complete",
|
||||
json={
|
||||
**COMPLETE_BODY,
|
||||
"file_hash": "a" * 64,
|
||||
"client_upload_id": "tok-1",
|
||||
"file_size": 5_000_000,
|
||||
},
|
||||
)
|
||||
assert r1.json()["duplicated"] is False
|
||||
# 第二次:同名同大小但 hash 不同(新视频内容不同);
|
||||
# 注意 client_upload_id 也必须不同,否则会先被 token 命中
|
||||
r2 = client.post(
|
||||
"/api/v1/direct/complete",
|
||||
json={
|
||||
**COMPLETE_BODY,
|
||||
"storage_key": "uploads/retry4/IMG_2282.MOV",
|
||||
"file_hash": "b" * 64,
|
||||
"client_upload_id": "tok-2",
|
||||
"file_size": 5_000_000,
|
||||
},
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["duplicated"] is False
|
||||
assert len(asset_repo.created) == 2
|
||||
|
||||
def test_fallback_dedup_ignores_ready_history(self):
|
||||
"""READY 历史同名素材不触发兜底(允许用户再次上传同名文件)。"""
|
||||
ready = Asset(
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""#1743 批量变体独立选片纯核心测试(packages/domain/variant_plan_selector.py)。
|
||||
|
||||
覆盖:
|
||||
- 固定种子下 N 次独立选片:素材组合/片段顺序/起点显著不同(降重核心)
|
||||
- 批次内同素材区间重叠 >20% 触发避让重选
|
||||
- 异常输入:空源片段/空素材池/时长全 0 → ValueError(严禁退回同源)
|
||||
- 非 main 片段(intro/outro/overlay)保留源骨架素材,仅重算起点
|
||||
- 跨变体 batch_segments 就地累加(串行选片天然避让)
|
||||
- 文案/转场/速度等骨架字段透传
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
for sub in ("packages", ""):
|
||||
p = str(REPO_ROOT / sub) if sub else str(REPO_ROOT)
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
from packages.domain import variant_plan_selector as vps # noqa: E402
|
||||
from packages.domain.variant_plan_selector import ( # noqa: E402
|
||||
BATCH_CLIP_OVERLAP_LIMIT,
|
||||
_clip_overlap_ratio,
|
||||
reselect_clips_for_variant,
|
||||
)
|
||||
|
||||
|
||||
def _source_clips(assets=("a1", "a2", "a3"), dur=5.0, with_non_main=False):
|
||||
"""构造源片段骨架:main 片段若干,可选 intro/outro 固定角色片段。"""
|
||||
clips = []
|
||||
order = 0
|
||||
if with_non_main:
|
||||
clips.append(
|
||||
{
|
||||
"order": order,
|
||||
"asset_id": "intro_asset",
|
||||
"start_time": 0.0,
|
||||
"duration": 3.0,
|
||||
"clip_type": "intro",
|
||||
"playback_speed": 1.0,
|
||||
"transition_effect": "fade",
|
||||
"transition_duration": 0.5,
|
||||
"text_content": "片头",
|
||||
"config": {"role": "intro"},
|
||||
}
|
||||
)
|
||||
order += 1
|
||||
for i, aid in enumerate(assets):
|
||||
clips.append(
|
||||
{
|
||||
"order": order,
|
||||
"asset_id": aid,
|
||||
"start_time": float(i * 10),
|
||||
"duration": dur,
|
||||
"clip_type": "main",
|
||||
"playback_speed": 1.2,
|
||||
"transition_effect": "cut",
|
||||
"transition_duration": 0.0,
|
||||
"text_content": f"文案{i}",
|
||||
"config": {},
|
||||
}
|
||||
)
|
||||
order += 1
|
||||
if with_non_main:
|
||||
clips.append(
|
||||
{
|
||||
"order": order,
|
||||
"asset_id": "outro_asset",
|
||||
"start_time": 0.0,
|
||||
"duration": 2.0,
|
||||
"clip_type": "outro",
|
||||
"playback_speed": 1.0,
|
||||
"transition_effect": "fade",
|
||||
"transition_duration": 0.5,
|
||||
"text_content": "片尾",
|
||||
"config": {"role": "outro"},
|
||||
}
|
||||
)
|
||||
return clips
|
||||
|
||||
|
||||
def _durations(asset_ids, total=120.0, extra=None):
|
||||
d = {a: total for a in asset_ids}
|
||||
if extra:
|
||||
d.update(extra)
|
||||
return d
|
||||
|
||||
|
||||
class TestReselectClipsValidation:
|
||||
def test_empty_source_clips_raises(self):
|
||||
with pytest.raises(ValueError, match="源 plan 无片段"):
|
||||
reselect_clips_for_variant(
|
||||
[],
|
||||
["a1"],
|
||||
asset_durations={"a1": 60.0},
|
||||
rng=random.Random(1),
|
||||
)
|
||||
|
||||
def test_empty_asset_pool_raises(self):
|
||||
with pytest.raises(ValueError, match="素材池为空"):
|
||||
reselect_clips_for_variant(
|
||||
_source_clips(),
|
||||
[],
|
||||
asset_durations={},
|
||||
rng=random.Random(1),
|
||||
)
|
||||
|
||||
def test_all_zero_duration_raises(self):
|
||||
with pytest.raises(ValueError, match="时长全部未知"):
|
||||
reselect_clips_for_variant(
|
||||
_source_clips(),
|
||||
["a1", "a2"],
|
||||
asset_durations={"a1": 0.0, "a2": 0.0},
|
||||
rng=random.Random(1),
|
||||
)
|
||||
|
||||
|
||||
class TestReselectClipsDifferentiation:
|
||||
def test_three_variants_differ_in_assets_order_and_starts(self):
|
||||
"""核心验收:固定种子连续 3 次独立选片,素材组合/顺序/起点显著不同。"""
|
||||
source = _source_clips(assets=("a1", "a2", "a3", "a4"))
|
||||
pool = ["a1", "a2", "a3", "a4", "a5", "a6"]
|
||||
durations = _durations(pool, total=300.0)
|
||||
|
||||
batch_segments: dict = {}
|
||||
variants = []
|
||||
for seed in range(3):
|
||||
clips = reselect_clips_for_variant(
|
||||
source,
|
||||
pool,
|
||||
asset_durations=durations,
|
||||
batch_segments=batch_segments, # 串行调用:上一变体区间参与避让
|
||||
rng=random.Random(100 + seed),
|
||||
)
|
||||
variants.append(clips)
|
||||
|
||||
main_sequences = []
|
||||
for clips in variants:
|
||||
main = [c for c in clips if c["clip_type"] == "main"]
|
||||
main_sequences.append([(c["asset_id"], round(c["start_time"], 2)) for c in main])
|
||||
|
||||
# 1) 每个变体片段数与源骨架一致
|
||||
for clips in variants:
|
||||
main_clips = [c for c in clips if c["clip_type"] == "main"]
|
||||
assert len(main_clips) == 4
|
||||
|
||||
# 2) 三个变体的素材序列不全相同(素材洗牌 + main 顺序洗牌生效)
|
||||
seq_sets = {tuple(a for a, _ in seq) for seq in main_sequences}
|
||||
assert len(seq_sets) >= 2, f"变体素材序列应存在差异,实际全部相同: {seq_sets}"
|
||||
|
||||
# 3) 起点组合不全相同(起点重选生效)
|
||||
start_sets = {tuple(s for _, s in seq) for seq in main_sequences}
|
||||
assert len(start_sets) >= 2, f"变体起点组合应存在差异,实际全部相同: {start_sets}"
|
||||
|
||||
# 4) batch_segments 跨变体累加(串行避让链路存在)
|
||||
total_segments = sum(len(v) for v in batch_segments.values())
|
||||
assert total_segments >= 12, f"3 变体 × 4 main 片段应累加 >=12 区间,实际 {total_segments}"
|
||||
|
||||
def test_skeleton_fields_preserved(self):
|
||||
"""文案/转场/速度等骨架字段随片段透传(只换素材与起点)。"""
|
||||
source = _source_clips(assets=("a1", "a2"), with_non_main=True)
|
||||
pool = ["a1", "a2", "a3"]
|
||||
durations = _durations(pool, total=120.0, extra={"intro_asset": 30.0, "outro_asset": 30.0})
|
||||
|
||||
clips = reselect_clips_for_variant(
|
||||
source,
|
||||
pool,
|
||||
asset_durations=durations,
|
||||
rng=random.Random(7),
|
||||
)
|
||||
by_order = {c["order"]: c for c in clips}
|
||||
|
||||
# intro/outro 骨架字段保留
|
||||
intro = next(c for c in clips if c["clip_type"] == "intro")
|
||||
outro = next(c for c in clips if c["clip_type"] == "outro")
|
||||
assert intro["asset_id"] == "intro_asset"
|
||||
assert intro["text_content"] == "片头"
|
||||
assert intro["transition_effect"] == "fade"
|
||||
assert intro["transition_duration"] == 0.5
|
||||
assert outro["asset_id"] == "outro_asset"
|
||||
assert outro["text_content"] == "片尾"
|
||||
|
||||
# main 片段文案/速度随骨架 order 保留
|
||||
for c in clips:
|
||||
if c["clip_type"] == "main":
|
||||
assert c["playback_speed"] == 1.2
|
||||
assert c["text_content"].startswith("文案")
|
||||
|
||||
|
||||
class TestBatchOverlapAvoidance:
|
||||
def test_overlap_ratio_calculation(self):
|
||||
segs = {"a1": [(10.0, 20.0)]} # 已占 10s 区间
|
||||
# 新区间 [10,20) 完全重叠 → 1.0
|
||||
assert _clip_overlap_ratio("a1", 10.0, 10.0, segs) == pytest.approx(1.0)
|
||||
# 新区间 [20,30) 零重叠 → 0.0
|
||||
assert _clip_overlap_ratio("a1", 20.0, 10.0, segs) == pytest.approx(0.0)
|
||||
# 新区间 [15,25) 重叠 5s / 10s → 0.5
|
||||
assert _clip_overlap_ratio("a1", 15.0, 10.0, segs) == pytest.approx(0.5)
|
||||
# 空 asset / 零时长 → 0
|
||||
assert _clip_overlap_ratio("", 0.0, 10.0, segs) == 0.0
|
||||
assert _clip_overlap_ratio("a1", 10.0, 0.0, segs) == 0.0
|
||||
|
||||
def test_over_limit_triggers_reselect_to_non_overlapping(self):
|
||||
"""批次已占满素材前段时,避让重选应把起点挪到重叠 ≤20% 的位置。"""
|
||||
source = _source_clips(assets=("a1",), dur=10.0)
|
||||
durations = {"a1": 120.0}
|
||||
# 批次已选区间:a1 [0, 100) 几乎占满前段
|
||||
batch_segments = {"a1": [(0.0, 100.0)]}
|
||||
|
||||
# _resolve_start_time 第一次返回高重叠起点(2.0),之后 rng 抖动应找到低重叠位置
|
||||
call_count = {"n": 0}
|
||||
|
||||
def fake_resolve(asset_id, clip_duration, asset_durations, used_segments, scene_points=None, on_exhausted=None):
|
||||
call_count["n"] += 1
|
||||
return 2.0 if call_count["n"] == 1 else None # 后续回退 rng.uniform
|
||||
|
||||
with patch.object(vps, "_resolve_start_time", side_effect=fake_resolve):
|
||||
# rng.uniform 返回 105.0(与 [0,100) 零重叠);rng 是 random.Random 实例,
|
||||
# 需 patch 类方法 uniform 才能生效
|
||||
with patch.object(random.Random, "uniform", return_value=105.0):
|
||||
clips = reselect_clips_for_variant(
|
||||
source,
|
||||
["a1"],
|
||||
asset_durations=durations,
|
||||
batch_segments=batch_segments,
|
||||
rng=random.Random(3),
|
||||
)
|
||||
|
||||
main = [c for c in clips if c["clip_type"] == "main"]
|
||||
assert len(main) == 1
|
||||
ratio = _clip_overlap_ratio("a1", main[0]["start_time"], 10.0, {"a1": [(0.0, 100.0)]})
|
||||
assert ratio <= BATCH_CLIP_OVERLAP_LIMIT, f"避让后重叠应 ≤20%,实际 {ratio:.2%}"
|
||||
assert main[0]["start_time"] == pytest.approx(105.0, abs=0.01)
|
||||
|
||||
def test_first_variant_segments_become_avoidance_target(self):
|
||||
"""变体 0 选定区间后,变体 1 选同素材时批次区间生效(不与源区间完全重合)。"""
|
||||
source = _source_clips(assets=("a1", "a2"), dur=8.0)
|
||||
pool = ["a1", "a2", "a3"]
|
||||
durations = _durations(pool, total=600.0)
|
||||
|
||||
batch_segments: dict = {}
|
||||
v1 = reselect_clips_for_variant(
|
||||
source, pool, asset_durations=durations, batch_segments=batch_segments, rng=random.Random(11)
|
||||
)
|
||||
v1_main = [(c["asset_id"], c["start_time"], c["duration"]) for c in v1 if c["clip_type"] == "main"]
|
||||
|
||||
# 快照 v1 之后的批次避让集(v2 调用会就地追加 v2 自身区间,断言必须用调用前快照)
|
||||
import copy
|
||||
|
||||
batch_snapshot = copy.deepcopy(batch_segments)
|
||||
|
||||
v2 = reselect_clips_for_variant(
|
||||
source, pool, asset_durations=durations, batch_segments=batch_segments, rng=random.Random(12)
|
||||
)
|
||||
# v2 与 v1(快照)同素材片段的区间重叠占比均 ≤20%
|
||||
for c in v2:
|
||||
if c["clip_type"] != "main" or not c["asset_id"]:
|
||||
continue
|
||||
ratio = _clip_overlap_ratio(c["asset_id"], c["start_time"], c["duration"], batch_snapshot)
|
||||
assert (
|
||||
ratio <= BATCH_CLIP_OVERLAP_LIMIT + 1e-6
|
||||
), f"变体间片段重叠超限: asset={c['asset_id']} start={c['start_time']} ratio={ratio:.2%}"
|
||||
# v1 片段确实进入了批次避让集
|
||||
assert any(a in batch_segments for a, _, _ in v1_main)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user