拆镜复刻、爆款开头复刻管理后台完成

This commit is contained in:
2026-06-17 15:42:07 +08:00
parent 5d5e6485ee
commit 0d647a7171
31 changed files with 3042 additions and 2777 deletions
@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
@@ -241,12 +242,37 @@ async def create_task(
)
async def list_tasks(
status: ModuleProjectStatusEnum | None = Query(None, description="总任务状态筛选:pending=已创建,waiting_user=等待用户操作,processing=处理中,completed=完成,failed=失败,cancelled=取消;为空不过滤"),
keyword: str | None = Query(None, description="关键词搜索:项目ID、标题、生成项目名称、素材项目名称、核心内容点;普通用户只在自己的数据内搜索"),
user_id: str | None = Query(None, description="管理员专用:按用户ID筛选;普通用户不可使用"),
user_name: str | None = Query(None, description="管理员专用:按用户名模糊筛选;普通用户不可使用"),
created_start: datetime | None = Query(None, description="管理员专用:创建时间开始,ISO datetime;普通用户不可使用"),
created_end: datetime | None = Query(None, description="管理员专用:创建时间结束,ISO datetime;普通用户不可使用"),
page: int = Query(1, ge=1, description="分页页码,从1开始"),
page_size: int = Query(20, ge=1, le=100, description="每页数量,范围1-100"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await list_hot_opening_projects(db, current_user=current_user, status=status.value if status else None, page=page, page_size=page_size)
admin_only_params = {
"user_id": user_id,
"user_name": user_name,
"created_start": created_start,
"created_end": created_end,
}
if not _safe_user_is_admin(current_user) and any(value is not None and str(value).strip() != "" for value in admin_only_params.values()):
raise HTTPException(status_code=403, detail="当前搜索条件仅管理员可用")
return await list_hot_opening_projects(
db,
current_user=current_user,
status=status.value if status else None,
keyword=keyword,
user_id=user_id,
user_name=user_name,
created_start=created_start,
created_end=created_end,
page=page,
page_size=page_size,
)
@router.get(
+19 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
@@ -293,12 +294,25 @@ async def list_shot_task_sets(
status: ShotTaskSetStatusEnum | None = Query(None, description="总任务状态筛选:pending_analysis=等待分析,analyzing=分析中,analysis_completed=分析完成,analysis_failed=分析失败,splitting=拆镜中,split_completed=拆镜完成,partial_failed=部分失败,failed=失败,deleted=已软删"),
analysis_status: ShotAnalysisStatusEnum | None = Query(None, description="原视频分析状态筛选:pending=待分析,processing=分析中,completed=分析完成,failed=分析失败"),
split_status: ShotSplitStatusEnum | None = Query(None, description="拆镜状态筛选:none=尚未拆镜,pending=待拆镜,processing=拆镜中,completed=拆镜完成,failed=拆镜失败,retry_waiting=等待恢复重试"),
keyword: str | None = Query(None, description="标题/原视频内容/分类/受众关键词,模糊搜索;为空不过滤"),
keyword: str | None = Query(None, description="标题/原视频内容/分类/受众关键词,模糊搜索;普通用户只在自己的数据内搜索"),
user_id: str | None = Query(None, description="管理员专用:按用户ID筛选;普通用户不可使用"),
user_name: str | None = Query(None, description="管理员专用:按用户名模糊筛选;普通用户不可使用"),
created_start: datetime | None = Query(None, description="管理员专用:创建时间开始,ISO datetime;普通用户不可使用"),
created_end: datetime | None = Query(None, description="管理员专用:创建时间结束,ISO datetime;普通用户不可使用"),
page: int = Query(1, ge=1, description="分页页码,从1开始"),
page_size: int = Query(20, ge=1, le=100, description="每页数量,范围1-100"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
admin_only_params = {
"user_id": user_id,
"user_name": user_name,
"created_start": created_start,
"created_end": created_end,
}
if not _safe_user_is_admin(current_user) and any(value is not None and str(value).strip() != "" for value in admin_only_params.values()):
raise HTTPException(status_code=403, detail="当前搜索条件仅管理员可用")
return await list_task_sets(
db,
current_user=current_user,
@@ -306,6 +320,10 @@ async def list_shot_task_sets(
analysis_status=analysis_status.value if analysis_status else None,
split_status=split_status.value if split_status else None,
keyword=keyword,
user_id=user_id,
user_name=user_name,
created_start=created_start,
created_end=created_end,
page=page,
page_size=page_size,
)
@@ -425,6 +425,8 @@ class HotOpeningVideoGenerationOut(BaseModel):
class HotOpeningTaskDetailOut(BaseModel):
id: str = Field(..., description="总任务项目ID。这个ID就是前端项目ID")
project_id: str = Field(..., description="兼容前端命名,等同于 id")
user_id: str | None = Field(None, description="所属用户ID;管理员后台排查使用")
user_name: str | None = Field(None, description="所属用户名;管理员后台排查使用")
module: str = Field(..., description="模块标识,爆款开头复刻固定为 hot_opening_replicate")
title: str | None = Field(None, description="项目标题,默认取生成项目名称")
status: str = Field(..., description="总任务状态:pending=已创建,waiting_user=等待用户操作,processing=处理中,completed=完成,failed=失败,cancelled=取消")
@@ -445,11 +447,15 @@ class HotOpeningTaskDetailOut(BaseModel):
class HotOpeningTaskListItemOut(BaseModel):
id: str = Field(..., description="总任务项目ID。这个ID就是前端项目ID")
project_id: str = Field(..., description="兼容前端命名,等同于 id")
user_id: str | None = Field(None, description="所属用户ID;管理员后台排查使用")
user_name: str | None = Field(None, description="所属用户名;管理员后台排查使用")
module: str = Field(..., description="模块标识")
title: str | None = Field(None, description="项目标题")
status: str = Field(..., description="总任务状态:pending=已创建,waiting_user=等待用户操作,processing=处理中,completed=完成,failed=失败,cancelled=取消")
current_step_code: str | None = Field(None, description="当前步骤")
source_project_name: str | None = Field(None, description="视频素材内容项目名称,来源于第1步素材输入")
target_project_name: str | None = Field(None, description="生成项目名称,来源于第1步素材输入")
core_content_point: str | None = Field(None, description="核心内容点,来源于第1步素材输入")
final_image_url: str | None = Field(None, description="最终图片 URL")
final_video_url: str | None = Field(None, description="最终视频 URL")
final_video_cover_url: str | None = Field(None, description="最终视频封面 URL")
@@ -428,6 +428,8 @@ class ShotReplicateVideoGenerationOut(BaseModel):
class ShotReplicateTaskDetailOut(BaseModel):
id: str = Field(..., description="总任务项目ID。这个ID就是前端项目ID")
project_id: str = Field(..., description="兼容前端命名,等同于 id")
user_id: str | None = Field(None, description="所属用户ID;管理员后台排查使用")
user_name: str | None = Field(None, description="所属用户名;管理员后台排查使用")
module: str = Field(..., description="模块标识,拆镜复刻固定为 shot_replicate")
title: str | None = Field(None, description="项目标题,默认取生成项目名称")
status: str = Field(..., description="总任务状态:pending/waiting_user/processing/completed/failed/cancelled")
@@ -579,6 +581,10 @@ class ShotTaskSetListQuery(BaseModel):
analysis_status: str | None = Field(None, description="分析状态筛选:pending/processing/completed/failed")
split_status: str | None = Field(None, description="拆镜状态筛选:none/pending/processing/completed/failed/retry_waiting")
keyword: str | None = Field(None, description="标题/内容关键词,模糊搜索")
user_id: str | None = Field(None, description="管理员专用:用户ID筛选")
user_name: str | None = Field(None, description="管理员专用:用户名模糊筛选")
created_start: NaiveDatetimeOptional = Field(None, description="管理员专用:创建时间开始")
created_end: NaiveDatetimeOptional = Field(None, description="管理员专用:创建时间结束")
page: int = Field(1, ge=1, description="页码")
page_size: int = Field(20, ge=1, le=100, description="每页数量")
@@ -587,6 +593,8 @@ class ShotTaskSetOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str = Field(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id")
user_id: str | None = Field(None, description="所属用户ID;管理员后台排查使用")
user_name: str | None = Field(None, description="所属用户名;管理员后台排查使用")
title: str | None = Field(None, description="拆镜总任务标题,可为空")
video_url: str = Field(..., description="原视频 URL,来自已有上传接口")
video_duration_seconds: float = Field(..., description="原视频时长,单位秒,允许浮点")
@@ -659,6 +667,9 @@ class ShotSegmentOut(BaseModel):
split_last_error: str | None = Field(None, description="最近一次切割失败原因")
analysis_error_message: str | None = Field(None, description="片段分析失败原因")
module_project_id: str | None = Field(None, description="由该片段创建的拆镜复刻项目ID,即 module_generation_projects.id")
module_project_title: str | None = Field(None, description="关联拆镜复刻项目标题;后台片段列表展示使用")
module_project_status: str | None = Field(None, description="关联拆镜复刻项目状态;后台片段列表展示使用")
module_project_current_step_code: str | None = Field(None, description="关联拆镜复刻项目当前步骤;后台片段列表展示使用")
created_at: NaiveDatetimeOptional = Field(None, description="创建时间")
updated_at: NaiveDatetimeOptional = Field(None, description="更新时间")
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy import String, cast, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -486,9 +486,16 @@ async def project_to_detail_out(db: AsyncSession, project: ModuleGenerationProje
video_url = video_generate_output.get("result_video_url") or (video_chat.video_url if video_chat else None) or project.final_video_url
cover_url = video_generate_output.get("result_video_cover_url") or (video_chat.video_cover_url if video_chat else None) or project.final_video_cover_url
user_name: str | None = None
if project.user_id:
user_result = await db.execute(select(User.username).where(User.id == project.user_id).limit(1))
user_name = user_result.scalar_one_or_none()
return HotOpeningTaskDetailOut(
id=project.id,
project_id=project.id,
user_id=project.user_id,
user_name=user_name,
module=project.module,
title=project.title,
status=project.status,
@@ -586,40 +593,141 @@ async def create_hot_opening_project(db: AsyncSession, current_user: User, req:
return project
def _user_name_filter_subquery(value: str):
"""后台按用户名筛选时使用的子查询。
只在传入 user_name 时查询 users 表;keyword 不再关联 users,避免后台关键词搜索扩大查询范围。
"""
like = f"%{value.strip()}%"
return select(User.id).where(User.username.ilike(like))
async def _user_name_map_by_ids(db: AsyncSession, user_ids: set[str]) -> dict[str, str | None]:
"""一次性查询当前页涉及的用户,避免列表逐条查询用户表。"""
if not user_ids:
return {}
result = await db.execute(select(User.id, User.username).where(User.id.in_(list(user_ids))))
return {user_id: username for user_id, username in result.all()}
async def _current_step_map_by_project_ids(
db: AsyncSession,
*,
project_ids: set[str],
step_code: str,
) -> dict[str, ModuleGenerationStep]:
"""一次性查询当前页项目的指定步骤,避免列表逐条查 material_input。"""
if not project_ids:
return {}
result = await db.execute(
select(ModuleGenerationStep).where(
ModuleGenerationStep.project_id.in_(list(project_ids)),
ModuleGenerationStep.step_code == step_code,
ModuleGenerationStep.is_current.is_(True),
ModuleGenerationStep.deleted_at.is_(None),
)
)
return {step.project_id: step for step in result.scalars().all()}
async def list_hot_opening_projects(
db: AsyncSession,
*,
current_user: User,
status: str | None,
keyword: str | None = None,
user_id: str | None = None,
user_name: str | None = None,
created_start: datetime | None = None,
created_end: datetime | None = None,
page: int,
page_size: int,
) -> HotOpeningTaskListOut:
"""
爆款开头列表查询。
性能策略:
1. 列表接口只查列表字段,不调用 project_to_detail_out,避免 steps/chat/user 多重 N+1。
2. 主表分页查询完成后,再按当前页 project_ids 批量查 material_input。
3. 管理员需要展示 user_name 时,按当前页 user_id 去重后一次性查 User。
4. user_name 使用 IN (SELECT users.id ...) 子查询筛选;keyword 不查询 users 表。
"""
query = select(ModuleGenerationProject).where(
ModuleGenerationProject.module == MODULE,
ModuleGenerationProject.deleted_at.is_(None),
)
if not current_user.is_admin:
query = query.where(ModuleGenerationProject.user_id == current_user.id)
else:
if user_id and user_id.strip():
query = query.where(ModuleGenerationProject.user_id == user_id.strip())
if user_name and user_name.strip():
query = query.where(ModuleGenerationProject.user_id.in_(_user_name_filter_subquery(user_name)))
if created_start:
query = query.where(ModuleGenerationProject.created_at >= created_start)
if created_end:
query = query.where(ModuleGenerationProject.created_at <= created_end)
if status:
query = query.where(ModuleGenerationProject.status == status)
total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar_one()
result = await db.execute(query.order_by(ModuleGenerationProject.created_at.desc()).offset((page - 1) * page_size).limit(page_size))
projects = list(result.scalars().all())
if keyword and keyword.strip():
like = f"%{keyword.strip()}%"
material_step_subquery = (
select(ModuleGenerationStep.project_id).where(
ModuleGenerationStep.step_code == HotOpeningStepCodeEnum.MATERIAL_INPUT.value,
ModuleGenerationStep.is_current.is_(True),
ModuleGenerationStep.deleted_at.is_(None),
cast(ModuleGenerationStep.input_json, String).ilike(like),
)
)
query = query.where(
or_(
ModuleGenerationProject.id.ilike(like),
ModuleGenerationProject.title.ilike(like),
ModuleGenerationProject.current_step_code.ilike(like),
ModuleGenerationProject.error_message.ilike(like),
ModuleGenerationProject.id.in_(material_step_subquery),
)
)
total = int((await db.execute(select(func.count()).select_from(query.subquery()))).scalar() or 0)
result = await db.execute(
query.order_by(ModuleGenerationProject.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
projects = list(result.scalars().unique().all())
project_ids = {project.id for project in projects if project.id}
material_step_map = await _current_step_map_by_project_ids(
db,
project_ids=project_ids,
step_code=HotOpeningStepCodeEnum.MATERIAL_INPUT.value,
)
user_name_map: dict[str, str | None] = {}
if current_user.is_admin:
user_ids = {project.user_id for project in projects if project.user_id}
user_name_map = await _user_name_map_by_ids(db, user_ids)
items: list[HotOpeningTaskListItemOut] = []
for project in projects:
material_step = await _get_current_step_by_code(db, project.id, HotOpeningStepCodeEnum.MATERIAL_INPUT.value)
material_step = material_step_map.get(project.id)
material = _step_payload(material_step.input_json if material_step else None)
items.append(
HotOpeningTaskListItemOut(
id=project.id,
project_id=project.id,
user_id=project.user_id,
user_name=user_name_map.get(project.user_id) if current_user.is_admin and project.user_id else None,
module=project.module,
title=project.title,
status=project.status,
current_step_code=project.current_step_code,
source_project_name=material.get("source_project_name"),
target_project_name=material.get("target_project_name"),
core_content_point=material.get("core_content_point"),
final_image_url=build_resource_signed_url(project.final_image_url) if project.final_image_url else None,
final_video_url=build_resource_signed_url(project.final_video_url) if project.final_video_url else None,
final_video_cover_url=build_resource_signed_url(project.final_video_cover_url) if project.final_video_cover_url else None,
@@ -341,33 +341,162 @@ def fill_none_with_wu(value: Any) -> Any:
return value
TOP_LEVEL_SCHEMA_KEYS = tuple(CLIENT_SCHEMA_V1.keys()) + ("动态时间规划", "输出规格限制")
OBJECT_FIELD_WHITELISTS: dict[str, set[str]] = {
key: set(value.keys())
for key, value in CLIENT_SCHEMA_V1.items()
if isinstance(value, dict)
}
OBJECT_FIELD_WHITELISTS.setdefault("画面属性", set()).add("推荐分辨率")
OBJECT_FIELD_WHITELISTS["输出规格限制"] = {"支持时长", "支持比例", "支持分辨率", "当前推荐分辨率"}
ACTION_FLOW_CONTENT_KEYS = ("动作内容", "动作", "动作说明", "内容", "说明", "主体动作", "动作变化")
CAMERA_FLOW_CONTENT_KEYS = ("镜头内容", "镜头", "镜头说明", "运镜", "运镜说明", "内容", "说明")
TIME_PLAN_ALLOWED_KEYS = ("时间段", "阶段", "说明")
PLACEHOLDER_FLOW_TEXTS = {
"展示主体动作、核心卖点或主要视觉内容",
"展示主要动作、核心卖点或主要视觉内容",
"展示核心卖点或主要视觉内容",
"展示主体动作",
"",
}
EMPTY_VALUE_TEXTS = {"", "", "null", "None", "none", "未提及", "不适用"}
def _clean_schema_text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, (dict, list)):
try:
return json.dumps(value, ensure_ascii=False)
except Exception:
return str(value)
return str(value).strip()
def _is_empty_schema_value(value: Any) -> bool:
return _clean_schema_text(value) in EMPTY_VALUE_TEXTS
def _normalize_schema_object(section_key: str, value: Any) -> dict[str, Any]:
default_value = CLIENT_SCHEMA_V1.get(section_key)
if not isinstance(default_value, dict):
default_value = {}
source = value if isinstance(value, dict) else {}
merged = copy.deepcopy(default_value)
allowed_keys = OBJECT_FIELD_WHITELISTS.get(section_key, set(default_value.keys()))
for field_key in allowed_keys:
if field_key in source:
merged[field_key] = fill_none_with_wu(source.get(field_key))
return merged
def normalize_top_level_schema_fields(result: dict[str, Any]) -> dict[str, Any]:
"""按客户端视频提词 schema 白名单清洗顶层和普通对象字段。
AI 偶尔会把解释性文本作为 JSON key 输出。普通对象中的非预设字段直接过滤,
动作流程/镜头流程这类列表字段在后续 flow normalize 中单独处理。
"""
source = result if isinstance(result, dict) else {}
normalized: dict[str, Any] = {}
for key in TOP_LEVEL_SCHEMA_KEYS:
if key in OBJECT_FIELD_WHITELISTS:
normalized[key] = _normalize_schema_object(key, source.get(key))
elif key in source:
normalized[key] = fill_none_with_wu(source.get(key))
elif key in CLIENT_SCHEMA_V1:
normalized[key] = copy.deepcopy(CLIENT_SCHEMA_V1[key])
for key, default_value in CLIENT_SCHEMA_V1.items():
if key not in normalized:
normalized[key] = copy.deepcopy(default_value)
return normalized
def ensure_top_keys(result: dict[str, Any]) -> dict[str, Any]:
schema = copy.deepcopy(CLIENT_SCHEMA_V1)
for key, default_value in schema.items():
if key not in result:
result[key] = default_value
elif isinstance(default_value, dict) and isinstance(result.get(key), dict):
merged = copy.deepcopy(default_value)
merged.update(result[key])
result[key] = merged
return result
return normalize_top_level_schema_fields(result)
def _pick_flow_content(item: dict[str, Any], content_keys: tuple[str, ...]) -> str:
for key in content_keys:
if key in item and not _is_empty_schema_value(item.get(key)):
return _clean_schema_text(item.get(key))
return ""
def _collect_extra_flow_texts(
item: dict[str, Any],
*,
content_keys: tuple[str, ...],
allowed_keys: set[str],
) -> list[str]:
extras: list[str] = []
for key, value in item.items():
if key in allowed_keys or key in content_keys:
continue
key_text = _clean_schema_text(key)
value_text = _clean_schema_text(value)
if key_text and key_text not in EMPTY_VALUE_TEXTS:
extras.append(key_text)
if value_text and value_text not in EMPTY_VALUE_TEXTS and value_text != key_text:
extras.append(value_text)
# 去重但保留顺序,避免 AI 重复写入同一句。
deduped: list[str] = []
for text in extras:
if text not in deduped:
deduped.append(text)
return deduped
def _merge_flow_content(base_content: str, extra_texts: list[str], fallback: str) -> str:
base_content = _clean_schema_text(base_content)
if extra_texts and (not base_content or base_content in PLACEHOLDER_FLOW_TEXTS or base_content == fallback):
return "".join(extra_texts)
parts = [base_content] if base_content and base_content not in EMPTY_VALUE_TEXTS else []
for text in extra_texts:
if text and text not in parts:
parts.append(text)
return "".join(parts) if parts else fallback
def _normalize_flow_item(
raw_item: Any,
*,
plan_item: dict[str, str],
content_key: str,
content_keys: tuple[str, ...],
) -> dict[str, str]:
item = raw_item if isinstance(raw_item, dict) else {}
allowed_keys = {"时间段", content_key}
base_content = _pick_flow_content(item, content_keys)
extra_texts = _collect_extra_flow_texts(item, content_keys=content_keys, allowed_keys=allowed_keys)
fallback = plan_item.get("说明") or ""
return {
"时间段": plan_item.get("时间段") or _clean_schema_text(item.get("时间段")) or "",
content_key: _merge_flow_content(base_content, extra_texts, fallback),
}
def _normalize_time_plan(plan: list[dict[str, str]], value: Any) -> list[dict[str, str]]:
source = value if isinstance(value, list) else []
normalized: list[dict[str, str]] = []
for index, plan_item in enumerate(plan):
raw_item = source[index] if index < len(source) and isinstance(source[index], dict) else {}
item: dict[str, str] = {
"时间段": plan_item.get("时间段") or _clean_schema_text(raw_item.get("时间段")) or "",
"阶段": _clean_schema_text(raw_item.get("阶段")) or plan_item.get("阶段") or "",
"说明": _clean_schema_text(raw_item.get("说明")) or plan_item.get("说明") or "",
}
# 动态时间规划只保留 时间段/阶段/说明,多余字段不入库。
normalized.append(item)
return normalized
def ensure_flow_matches_time_plan(result: dict[str, Any], duration: int) -> dict[str, Any]:
plan = build_time_plan(duration)
if not isinstance(result.get("动作流程"), list) or not result["动作流程"]:
result["动作流程"] = [
{"时间段": item["时间段"], "动作内容": item["说明"]}
for item in plan
]
if not isinstance(result.get("镜头流程"), list) or not result["镜头流程"]:
result["镜头流程"] = [
{"时间段": item["时间段"], "镜头内容": item["说明"]}
for item in plan
]
result["动作流程"] = _align_flow_time_ranges(result["动作流程"], plan, "动作内容")
result["镜头流程"] = _align_flow_time_ranges(result["镜头流程"], plan, "镜头内容")
result["动态时间规划"] = plan
result["动作流程"] = _align_flow_time_ranges(result.get("动作流程"), plan, "动作内容")
result["镜头流程"] = _align_flow_time_ranges(result.get("镜头流程"), plan, "镜头内容")
result["动态时间规划"] = _normalize_time_plan(plan, result.get("动态时间规划"))
return result
@@ -462,16 +591,20 @@ def clean_final_prompt_specs(schema: dict[str, Any], video_config: dict[str, Any
return schema
def _align_flow_time_ranges(flow: Any, plan: list[dict[str, str]], default_content_key: str) -> list[dict[str, Any]]:
def _align_flow_time_ranges(flow: Any, plan: list[dict[str, str]], default_content_key: str) -> list[dict[str, str]]:
source = flow if isinstance(flow, list) else []
aligned: list[dict[str, Any]] = []
content_keys = ACTION_FLOW_CONTENT_KEYS if default_content_key == "动作内容" else CAMERA_FLOW_CONTENT_KEYS
aligned: list[dict[str, str]] = []
for index, plan_item in enumerate(plan):
old_item = source[index] if index < len(source) and isinstance(source[index], dict) else {}
item = dict(old_item)
item["时间段"] = plan_item["时间段"]
if not any(k in item and str(item.get(k)).strip() for k in (default_content_key, "动作", "镜头", "说明", "内容")):
item[default_content_key] = plan_item["说明"]
aligned.append(item)
raw_item = source[index] if index < len(source) else {}
aligned.append(
_normalize_flow_item(
raw_item,
plan_item=plan_item,
content_key=default_content_key,
content_keys=content_keys,
)
)
return aligned
@@ -493,9 +493,16 @@ async def project_to_detail_out(db: AsyncSession, project: ModuleGenerationProje
video_url = video_generate_output.get("result_video_url") or (video_chat.video_url if video_chat else None) or project.final_video_url
cover_url = video_generate_output.get("result_video_cover_url") or (video_chat.video_cover_url if video_chat else None) or project.final_video_cover_url
user_name: str | None = None
if project.user_id:
user_result = await db.execute(select(User.username).where(User.id == project.user_id).limit(1))
user_name = user_result.scalar_one_or_none()
return ShotReplicateTaskDetailOut(
id=project.id,
project_id=project.id,
user_id=project.user_id,
user_name=user_name,
module=project.module,
title=project.title,
status=project.status,
@@ -593,6 +600,26 @@ async def create_shot_replicate_project(db: AsyncSession, current_user: User, re
return project
async def _current_step_map_by_project_ids(
db: AsyncSession,
*,
project_ids: set[str],
step_code: str,
) -> dict[str, ModuleGenerationStep]:
"""一次性查询当前页项目的指定步骤,避免列表逐条查 material_input。"""
if not project_ids:
return {}
result = await db.execute(
select(ModuleGenerationStep).where(
ModuleGenerationStep.project_id.in_(list(project_ids)),
ModuleGenerationStep.step_code == step_code,
ModuleGenerationStep.is_current.is_(True),
ModuleGenerationStep.deleted_at.is_(None),
)
)
return {step.project_id: step for step in result.scalars().all()}
async def list_shot_replicate_projects(
db: AsyncSession,
*,
@@ -601,6 +628,11 @@ async def list_shot_replicate_projects(
page: int,
page_size: int,
) -> ShotReplicateTaskListOut:
"""
拆镜复刻项目列表查询。
这个列表目前不是后台主入口,但仍避免逐条查 material_input,保持和爆款开头列表一致的批量查询策略。
"""
query = select(ModuleGenerationProject).where(
ModuleGenerationProject.module == MODULE,
ModuleGenerationProject.deleted_at.is_(None),
@@ -610,13 +642,23 @@ async def list_shot_replicate_projects(
if status:
query = query.where(ModuleGenerationProject.status == status)
total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar_one()
result = await db.execute(query.order_by(ModuleGenerationProject.created_at.desc()).offset((page - 1) * page_size).limit(page_size))
projects = list(result.scalars().all())
total = int((await db.execute(select(func.count()).select_from(query.subquery()))).scalar() or 0)
result = await db.execute(
query.order_by(ModuleGenerationProject.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
projects = list(result.scalars().unique().all())
project_ids = {project.id for project in projects if project.id}
material_step_map = await _current_step_map_by_project_ids(
db,
project_ids=project_ids,
step_code=ShotReplicateStepCodeEnum.MATERIAL_INPUT.value,
)
items: list[ShotReplicateTaskListItemOut] = []
for project in projects:
material_step = await _get_current_step_by_code(db, project.id, ShotReplicateStepCodeEnum.MATERIAL_INPUT.value)
material_step = material_step_map.get(project.id)
material = _step_payload(material_step.input_json if material_step else None)
items.append(
ShotReplicateTaskListItemOut(
@@ -7,7 +7,7 @@ from typing import Any
from fastapi import HTTPException
from app.config import settings
from sqlalchemy import func, select
from sqlalchemy import String, cast, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.shot_replicate import (
@@ -19,6 +19,7 @@ from app.enums.shot_replicate import (
ShotSplitStatusEnum,
ShotTaskSetStatusEnum,
)
from app.models.module_generation_project import ModuleGenerationProject
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.shot_replicate_task_set import ShotReplicateTaskSet
from app.models.user import User
@@ -85,26 +86,37 @@ def _normalize_suggestions(value: Any) -> list[dict[str, Any]]:
return normalized
def _task_set_to_out(task_set: ShotReplicateTaskSet) -> ShotTaskSetOut:
return ShotTaskSetOut.model_validate(task_set)
def _task_set_to_out(task_set: ShotReplicateTaskSet, user_name: str | None = None) -> ShotTaskSetOut:
data = ShotTaskSetOut.model_validate(task_set)
data.user_name = user_name
return data
def _task_set_to_detail_out(task_set: ShotReplicateTaskSet) -> ShotTaskSetDetailOut:
def _task_set_to_detail_out(task_set: ShotReplicateTaskSet, user_name: str | None = None) -> ShotTaskSetDetailOut:
suggestions = [ShotAISuggestionOut(**{k: v for k, v in item.items() if k != "raw"}) for item in _normalize_suggestions(task_set.ai_suggestion_json)]
base = ShotTaskSetDetailOut.model_validate(task_set)
base.user_name = user_name
base.ai_suggestions = suggestions
return base
def _segment_to_out(segment: ShotReplicateSegment) -> ShotSegmentOut:
def _segment_to_out(segment: ShotReplicateSegment, project: ModuleGenerationProject | None = None) -> ShotSegmentOut:
data = ShotSegmentOut.model_validate(segment)
data.segment_name = f"片段{segment.segment_index}"
if project:
data.module_project_title = project.title
data.module_project_status = project.status
data.module_project_current_step_code = project.current_step_code
return data
def _segment_to_detail_out(segment: ShotReplicateSegment) -> ShotSegmentDetailOut:
def _segment_to_detail_out(segment: ShotReplicateSegment, project: ModuleGenerationProject | None = None) -> ShotSegmentDetailOut:
data = ShotSegmentDetailOut.model_validate(segment)
data.segment_name = f"片段{segment.segment_index}"
if project:
data.module_project_title = project.title
data.module_project_status = project.status
data.module_project_current_step_code = project.current_step_code
return data
@@ -201,6 +213,23 @@ async def create_task_set(db: AsyncSession, *, current_user: User, req: ShotTask
return task_set
def _user_name_filter_subquery(value: str):
"""后台按用户名筛选时使用的子查询。
只在传入 user_name 时查询 users 表;keyword 不再关联 users,避免后台关键词搜索扩大查询范围。
"""
like = f"%{value.strip()}%"
return select(User.id).where(User.username.ilike(like))
async def _user_name_map_by_ids(db: AsyncSession, user_ids: set[str]) -> dict[str, str | None]:
"""一次性查询当前页涉及的用户,避免列表逐条查询用户表。"""
if not user_ids:
return {}
result = await db.execute(select(User.id, User.username).where(User.id.in_(list(user_ids))))
return {user_id: username for user_id, username in result.all()}
async def list_task_sets(
db: AsyncSession,
*,
@@ -209,39 +238,85 @@ async def list_task_sets(
analysis_status: str | None = None,
split_status: str | None = None,
keyword: str | None = None,
user_id: str | None = None,
user_name: str | None = None,
created_start: datetime | None = None,
created_end: datetime | None = None,
page: int = 1,
page_size: int = 20,
) -> ShotTaskSetListOut:
"""
拆镜总任务集列表查询。
性能策略:
1. 主列表不 join User,避免 count/list 复杂化。
2. 管理员按 user_name 搜索时使用 IN (SELECT users.id ...) 子查询;keyword 不查询 users 表。
3. 当前页数据取出后,再按 user_id 去重批量查 user_name,用于后台渲染。
"""
query = select(ShotReplicateTaskSet).where(ShotReplicateTaskSet.deleted_at.is_(None))
if not current_user.is_admin:
query = query.where(ShotReplicateTaskSet.user_id == current_user.id)
else:
if user_id and user_id.strip():
query = query.where(ShotReplicateTaskSet.user_id == user_id.strip())
if user_name and user_name.strip():
query = query.where(ShotReplicateTaskSet.user_id.in_(_user_name_filter_subquery(user_name)))
if created_start:
query = query.where(ShotReplicateTaskSet.created_at >= created_start)
if created_end:
query = query.where(ShotReplicateTaskSet.created_at <= created_end)
if status:
query = query.where(ShotReplicateTaskSet.status == status)
if analysis_status:
query = query.where(ShotReplicateTaskSet.analysis_status == analysis_status)
if split_status:
query = query.where(ShotReplicateTaskSet.split_status == split_status)
if keyword:
if keyword and keyword.strip():
like = f"%{keyword.strip()}%"
query = query.where(
(ShotReplicateTaskSet.title.ilike(like))
| (ShotReplicateTaskSet.original_video_content.ilike(like))
| (ShotReplicateTaskSet.original_video_category.ilike(like))
)
conditions = [
ShotReplicateTaskSet.id.ilike(like),
ShotReplicateTaskSet.title.ilike(like),
ShotReplicateTaskSet.original_video_content.ilike(like),
ShotReplicateTaskSet.original_video_category.ilike(like),
ShotReplicateTaskSet.original_video_audience.ilike(like),
cast(ShotReplicateTaskSet.ai_suggestion_json, String).ilike(like),
]
query = query.where(or_(*conditions))
total_result = await db.execute(select(func.count()).select_from(query.subquery()))
total = int(total_result.scalar() or 0)
rows = await db.execute(
result = await db.execute(
query.order_by(ShotReplicateTaskSet.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return ShotTaskSetListOut(total=total, page=page, page_size=page_size, items=[_task_set_to_out(item) for item in rows.scalars().all()])
task_sets = list(result.scalars().unique().all())
user_name_map: dict[str, str | None] = {}
if current_user.is_admin:
user_ids = {task_set.user_id for task_set in task_sets if task_set.user_id}
user_name_map = await _user_name_map_by_ids(db, user_ids)
return ShotTaskSetListOut(
total=total,
page=page,
page_size=page_size,
items=[
_task_set_to_out(
task_set,
user_name_map.get(task_set.user_id) if current_user.is_admin and task_set.user_id else None,
)
for task_set in task_sets
],
)
async def task_set_detail(db: AsyncSession, *, current_user: User, task_set_id: str) -> ShotTaskSetDetailOut:
task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user)
return _task_set_to_detail_out(task_set)
user_result = await db.execute(select(User.username).where(User.id == task_set.user_id).limit(1))
return _task_set_to_detail_out(task_set, user_result.scalar_one_or_none())
async def _next_segment_index(db: AsyncSession, task_set_id: str) -> int:
@@ -499,9 +574,13 @@ async def list_segments(
page_size: int = 20,
) -> ShotSegmentListOut:
await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user)
query = select(ShotReplicateSegment).where(
ShotReplicateSegment.task_set_id == task_set_id,
ShotReplicateSegment.deleted_at.is_(None),
query = (
select(ShotReplicateSegment, ModuleGenerationProject)
.outerjoin(ModuleGenerationProject, ModuleGenerationProject.id == ShotReplicateSegment.module_project_id)
.where(
ShotReplicateSegment.task_set_id == task_set_id,
ShotReplicateSegment.deleted_at.is_(None),
)
)
if not current_user.is_admin:
query = query.where(ShotReplicateSegment.user_id == current_user.id)
@@ -521,9 +600,18 @@ async def list_segments(
.offset((page - 1) * page_size)
.limit(page_size)
)
return ShotSegmentListOut(total=total, page=page, page_size=page_size, items=[_segment_to_out(item) for item in rows.scalars().all()])
return ShotSegmentListOut(
total=total,
page=page,
page_size=page_size,
items=[_segment_to_out(segment, project) for segment, project in rows.all()],
)
async def segment_detail(db: AsyncSession, *, current_user: User, segment_id: str) -> ShotSegmentDetailOut:
segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user)
return _segment_to_detail_out(segment)
project: ModuleGenerationProject | None = None
if segment.module_project_id:
project_result = await db.execute(select(ModuleGenerationProject).where(ModuleGenerationProject.id == segment.module_project_id).limit(1))
project = project_result.scalar_one_or_none()
return _segment_to_detail_out(segment, project)