拆镜复刻、爆款开头复刻管理后台完成
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user