素材云多模块API追加

This commit is contained in:
2026-07-01 10:28:30 +08:00
parent 838215736f
commit 87c0739863
6 changed files with 561 additions and 35 deletions
@@ -15,6 +15,12 @@ from app.models.project import Project
from app.models.image_engine import ImageEngine
from app.models.user import User
from app.models.video_engine import VideoEngine
from app.enums.generation_history import (
GenerationHistorySourceEnum,
get_generation_history_source_label,
get_generation_history_task_mode,
normalize_generation_history_source,
)
from app.schemas.generation_ai import (
GenerationAIEngineGroupOut,
GenerationAIEngineOptionsOut,
@@ -31,11 +37,15 @@ from app.services.generation_billing_service import (
from app.services.resource_accounting_service import (
SOURCE_MODEL_CHAT_TASK,
SOURCE_MODEL_GENERATION_RECORD,
batch_get_generated_resource_id_map,
batch_get_generated_resource_info_map,
soft_delete_chat_task_resources,
)
from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.generation_history_meta_service import (
GenerationHistoryMeta,
batch_load_generation_history_meta_map,
build_empty_history_meta,
)
from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.utils.id_gen import generate_id
@@ -333,15 +343,42 @@ def record_to_out(
task: ChatGenerationTask,
is_admin: bool = False,
generated_resource_id: str | None = None,
file_name: str | None = None,
history_meta: GenerationHistoryMeta | None = None,
) -> GenerationAITaskOut:
refs = _parse_json(task.media_references)
snapshot = engine_snapshot_out(_parse_json(task.engine_snapshot_json))
source = GenerationHistorySourceEnum.CHAT_TASK
try:
source = GenerationHistorySourceEnum(
"chat_task" if task.generation_mode == "chatapi_async" else str(task.generation_mode or "chat_task")
)
except ValueError:
source = GenerationHistorySourceEnum.CHAT_TASK
meta = history_meta or build_empty_history_meta(source)
return GenerationAITaskOut(
id=task.id,
user_id=task.user_id if is_admin else None,
user_name=getattr(task, "username", None) if is_admin else None,
project_id=None,
generated_resource_id=generated_resource_id,
file_name=file_name,
history_source=meta.get("history_source"),
history_source_label=meta.get("history_source_label"),
module_project_id=meta.get("module_project_id"),
module_project_title=meta.get("module_project_title"),
module_step_id=meta.get("module_step_id"),
module_step_code=meta.get("module_step_code"),
hot_opening_project_id=meta.get("hot_opening_project_id"),
hot_opening_project_title=meta.get("hot_opening_project_title"),
shot_replicate_project_id=meta.get("shot_replicate_project_id"),
shot_replicate_project_title=meta.get("shot_replicate_project_title"),
shot_task_set_id=meta.get("shot_task_set_id"),
shot_segment_id=meta.get("shot_segment_id"),
shot_segment_index=meta.get("shot_segment_index"),
shot_segment_label=meta.get("shot_segment_label"),
gen_type=task.gen_type,
generation_mode=task.generation_mode,
pipeline_stage=task.pipeline_stage,
@@ -375,7 +412,6 @@ def record_to_out(
generated_at=task.generated_at,
)
def engine_snapshot_out(snapshot: dict) -> dict:
"""
从完整的 engine_snapshot 中过滤出需要返回的字段
@@ -460,18 +496,20 @@ def _normalize_history_gen_type(gen_type: str | None) -> str:
def _normalize_history_source(history_source: str | None) -> str:
"""Normalize history source query param.
def _normalize_history_source(history_source: str | None) -> GenerationHistorySourceEnum:
"""Normalize history_source query param.
默认保持原来的 chat_generation_tasks 历史;只有显式传 generation_record
才切换旧 generation_records 历史,避免影响现有前端。
默认保持原来的 chat_generation_tasks / chatapi_async 历史;
显式传 hot_opening_replicate 或 shot_replicate 时查询对应模块素材;
显式传 generation_record 时查询旧 generation_records 历史。
"""
value = (history_source or "chat_task").lower().strip()
if value in ("", "chat", "chat_task", "chat_generation_task", "chat_generation_tasks"):
return "chat_task"
if value in ("record", "records", "generation_record", "generation_records"):
return "generation_record"
raise HTTPException(status_code=400, detail="history_source 仅支持 chat_taskgeneration_record")
try:
return normalize_generation_history_source(history_source)
except ValueError:
raise HTTPException(
status_code=400,
detail="history_source 仅支持 chat_taskgeneration_record、hot_opening_replicate、shot_replicate",
)
def _history_day_to_str(value) -> str:
@@ -489,10 +527,13 @@ def _parse_history_date(value: str) -> date:
raise HTTPException(status_code=400, detail="generated_date 格式必须是 YYYY-MM-DD")
def _history_base_filters(user_id: str, gen_type: str):
def _history_base_filters(user_id: str, gen_type: str, source: GenerationHistorySourceEnum):
task_mode = get_generation_history_task_mode(source)
if not task_mode:
raise HTTPException(status_code=400, detail="history_source 不支持查询 ChatGenerationTask 历史")
return [
ChatGenerationTask.user_id == user_id,
ChatGenerationTask.generation_mode == "chatapi_async",
ChatGenerationTask.generation_mode == task_mode.value,
ChatGenerationTask.deleted_at.is_(None),
ChatGenerationTask.status == "completed",
ChatGenerationTask.gen_type == gen_type,
@@ -524,6 +565,20 @@ def generation_record_to_history_out(
project_name=project_name,
generated_resource_id=generated_resource_id,
file_name=file_name,
history_source=GenerationHistorySourceEnum.GENERATION_RECORD.value,
history_source_label=get_generation_history_source_label(GenerationHistorySourceEnum.GENERATION_RECORD),
module_project_id=None,
module_project_title=None,
module_step_id=None,
module_step_code=None,
hot_opening_project_id=None,
hot_opening_project_title=None,
shot_replicate_project_id=None,
shot_replicate_project_title=None,
shot_task_set_id=None,
shot_segment_id=None,
shot_segment_index=None,
shot_segment_label=None,
gen_type=record.gen_type,
generation_mode="generation_record",
pipeline_stage=None,
@@ -732,9 +787,10 @@ async def list_generation_history_grouped_days(
- 每页最多返回 10 个生成日期
- 每个日期分组内最多返回倒序前 10 条任务
- 只返回 completed 成功任务
- history_source 支持 chat_task / generation_record / hot_opening_replicate / shot_replicate
"""
source = _normalize_history_source(history_source)
if source == "generation_record":
if source == GenerationHistorySourceEnum.GENERATION_RECORD:
return await list_generation_record_history_grouped_days(
db=db,
user_id=user_id,
@@ -747,7 +803,7 @@ async def list_generation_history_grouped_days(
page = max(page, 1)
page_size = min(max(page_size, 1), HISTORY_DAY_PAGE_SIZE_MAX)
filters = _history_base_filters(user_id, gen_type)
filters = _history_base_filters(user_id, gen_type, source)
day_expr = func.date(ChatGenerationTask.generated_at).label("generated_date")
days_subquery = (
@@ -790,12 +846,17 @@ async def list_generation_history_grouped_days(
raw_groups.append((generated_day, day_total, tasks))
all_task_ids.extend(task.id for task in tasks)
resource_id_map = await batch_get_generated_resource_id_map(
resource_info_map = await batch_get_generated_resource_info_map(
db,
source_model=SOURCE_MODEL_CHAT_TASK,
source_ids=all_task_ids,
resource_type=gen_type,
)
history_meta_map = await batch_load_generation_history_meta_map(
db,
source=source,
chat_task_ids=all_task_ids,
)
groups = [
{
@@ -804,7 +865,9 @@ async def list_generation_history_grouped_days(
"items": [
record_to_out(
task,
generated_resource_id=resource_id_map.get(task.id),
generated_resource_id=resource_info_map.get(task.id, {}).get("resource_id"),
file_name=resource_info_map.get(task.id, {}).get("file_name"),
history_meta=history_meta_map.get(task.id),
)
for task in tasks
],
@@ -833,9 +896,10 @@ async def list_generation_history_day_items(
获取指定生成日期下的历史记录分页。
用于前端点击某一天后,继续加载该日期下的第 2 页、第 3 页数据。
history_source 支持 chat_task / generation_record / hot_opening_replicate / shot_replicate。
"""
source = _normalize_history_source(history_source)
if source == "generation_record":
if source == GenerationHistorySourceEnum.GENERATION_RECORD:
return await list_generation_record_history_day_items(
db=db,
user_id=user_id,
@@ -850,7 +914,7 @@ async def list_generation_history_day_items(
page = max(page, 1)
page_size = min(max(page_size, 1), 100)
filters = _history_base_filters(user_id, gen_type)
filters = _history_base_filters(user_id, gen_type, source)
day_expr = func.date(ChatGenerationTask.generated_at)
total = (
@@ -874,12 +938,18 @@ async def list_generation_history_day_items(
)
tasks = list(result.scalars().all())
resource_id_map = await batch_get_generated_resource_id_map(
task_ids = [task.id for task in tasks]
resource_info_map = await batch_get_generated_resource_info_map(
db,
source_model=SOURCE_MODEL_CHAT_TASK,
source_ids=[task.id for task in tasks],
source_ids=task_ids,
resource_type=gen_type,
)
history_meta_map = await batch_load_generation_history_meta_map(
db,
source=source,
chat_task_ids=task_ids,
)
return {
"generated_date": target_day.strftime("%Y-%m-%d"),
@@ -889,7 +959,9 @@ async def list_generation_history_day_items(
"items": [
record_to_out(
task,
generated_resource_id=resource_id_map.get(task.id),
generated_resource_id=resource_info_map.get(task.id, {}).get("resource_id"),
file_name=resource_info_map.get(task.id, {}).get("file_name"),
history_meta=history_meta_map.get(task.id),
)
for task in tasks
],
@@ -0,0 +1,283 @@
from __future__ import annotations
from typing import Any, TypedDict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.generation_history import (
GenerationHistorySourceEnum,
get_generation_history_source_label,
is_generation_history_module_source,
)
from app.models.module_generation_project import ModuleGenerationProject
from app.models.module_generation_step import ModuleGenerationStep
from app.models.shot_replicate_segment import ShotReplicateSegment
class GenerationHistoryMeta(TypedDict):
"""素材云历史项模块上下文回填字段。"""
history_source: str | None
history_source_label: str | None
module_project_id: str | None
module_project_title: str | None
module_step_id: str | None
module_step_code: str | None
hot_opening_project_id: str | None
hot_opening_project_title: str | None
shot_replicate_project_id: str | None
shot_replicate_project_title: str | None
shot_task_set_id: str | None
shot_segment_id: str | None
shot_segment_index: int | None
shot_segment_label: str | None
class _StepLinkInfo(TypedDict):
module_project_id: str | None
module_step_id: str | None
module_step_code: str | None
module: str | None
class _ProjectInfo(TypedDict):
module_project_id: str
module_project_title: str | None
module: str | None
class _ShotSegmentInfo(TypedDict):
shot_task_set_id: str | None
shot_segment_id: str | None
shot_segment_index: int | None
shot_segment_label: str | None
def _unique(values: list[str] | tuple[str, ...]) -> list[str]:
return list(dict.fromkeys(str(value) for value in values if value))
def _segment_label(segment_index: int | None) -> str | None:
if segment_index is None:
return None
return f"片段{segment_index}"
def build_empty_history_meta(source: GenerationHistorySourceEnum) -> GenerationHistoryMeta:
"""构造统一历史字段,非对应模块字段保持 null。"""
return {
"history_source": source.value,
"history_source_label": get_generation_history_source_label(source),
"module_project_id": None,
"module_project_title": None,
"module_step_id": None,
"module_step_code": None,
"hot_opening_project_id": None,
"hot_opening_project_title": None,
"shot_replicate_project_id": None,
"shot_replicate_project_title": None,
"shot_task_set_id": None,
"shot_segment_id": None,
"shot_segment_index": None,
"shot_segment_label": None,
}
async def _load_step_link_map(
db: AsyncSession,
*,
chat_task_ids: list[str],
source: GenerationHistorySourceEnum,
) -> dict[str, _StepLinkInfo]:
ids = _unique(chat_task_ids)
if not ids:
return {}
stmt = (
select(
ModuleGenerationStep.chat_task_id.label("chat_task_id"),
ModuleGenerationStep.id.label("module_step_id"),
ModuleGenerationStep.project_id.label("module_project_id"),
ModuleGenerationStep.step_code.label("module_step_code"),
ModuleGenerationStep.module.label("module"),
ModuleGenerationStep.is_current.label("is_current"),
ModuleGenerationStep.updated_at.label("updated_at"),
)
.where(
ModuleGenerationStep.deleted_at.is_(None),
ModuleGenerationStep.chat_task_id.in_(ids),
ModuleGenerationStep.module == source.value,
)
.order_by(
ModuleGenerationStep.chat_task_id.asc(),
ModuleGenerationStep.is_current.desc(),
ModuleGenerationStep.updated_at.desc(),
)
)
rows = (await db.execute(stmt)).mappings().all()
link_map: dict[str, _StepLinkInfo] = {}
for row in rows:
chat_task_id = row["chat_task_id"]
if not chat_task_id or chat_task_id in link_map:
continue
link_map[chat_task_id] = {
"module_project_id": row["module_project_id"],
"module_step_id": row["module_step_id"],
"module_step_code": row["module_step_code"],
"module": row["module"],
}
return link_map
async def _load_project_map(
db: AsyncSession,
*,
module_project_ids: list[str],
) -> dict[str, _ProjectInfo]:
ids = _unique(module_project_ids)
if not ids:
return {}
stmt = (
select(
ModuleGenerationProject.id.label("module_project_id"),
ModuleGenerationProject.title.label("module_project_title"),
ModuleGenerationProject.module.label("module"),
)
.where(
ModuleGenerationProject.deleted_at.is_(None),
ModuleGenerationProject.id.in_(ids),
)
)
return {
row["module_project_id"]: {
"module_project_id": row["module_project_id"],
"module_project_title": row["module_project_title"],
"module": row["module"],
}
for row in (await db.execute(stmt)).mappings().all()
if row["module_project_id"]
}
async def _load_shot_segment_map(
db: AsyncSession,
*,
module_project_ids: list[str],
) -> dict[str, _ShotSegmentInfo]:
ids = _unique(module_project_ids)
if not ids:
return {}
stmt = (
select(
ShotReplicateSegment.module_project_id.label("module_project_id"),
ShotReplicateSegment.id.label("shot_segment_id"),
ShotReplicateSegment.task_set_id.label("shot_task_set_id"),
ShotReplicateSegment.segment_index.label("shot_segment_index"),
ShotReplicateSegment.updated_at.label("updated_at"),
)
.where(
ShotReplicateSegment.deleted_at.is_(None),
ShotReplicateSegment.module_project_id.in_(ids),
)
.order_by(
ShotReplicateSegment.module_project_id.asc(),
ShotReplicateSegment.updated_at.desc(),
)
)
segment_map: dict[str, _ShotSegmentInfo] = {}
rows = (await db.execute(stmt)).mappings().all()
for row in rows:
module_project_id = row["module_project_id"]
if not module_project_id or module_project_id in segment_map:
continue
segment_index = row["shot_segment_index"]
segment_map[module_project_id] = {
"shot_task_set_id": row["shot_task_set_id"],
"shot_segment_id": row["shot_segment_id"],
"shot_segment_index": segment_index,
"shot_segment_label": _segment_label(segment_index),
}
return segment_map
def _merge_meta(
*,
source: GenerationHistorySourceEnum,
step_info: _StepLinkInfo | None,
project_info: _ProjectInfo | None,
shot_info: _ShotSegmentInfo | None,
) -> GenerationHistoryMeta:
meta = build_empty_history_meta(source)
module_project_id = step_info["module_project_id"] if step_info else None
module_step_id = step_info["module_step_id"] if step_info else None
module_step_code = step_info["module_step_code"] if step_info else None
module_project_title = project_info["module_project_title"] if project_info else None
meta["module_project_id"] = module_project_id
meta["module_project_title"] = module_project_title
meta["module_step_id"] = module_step_id
meta["module_step_code"] = module_step_code
if source == GenerationHistorySourceEnum.HOT_OPENING_REPLICATE:
meta["hot_opening_project_id"] = module_project_id
meta["hot_opening_project_title"] = module_project_title
elif source == GenerationHistorySourceEnum.SHOT_REPLICATE:
meta["shot_replicate_project_id"] = module_project_id
meta["shot_replicate_project_title"] = module_project_title
if shot_info:
meta["shot_task_set_id"] = shot_info["shot_task_set_id"]
meta["shot_segment_id"] = shot_info["shot_segment_id"]
meta["shot_segment_index"] = shot_info["shot_segment_index"]
meta["shot_segment_label"] = shot_info["shot_segment_label"]
return meta
async def batch_load_generation_history_meta_map(
db: AsyncSession,
*,
source: GenerationHistorySourceEnum,
chat_task_ids: list[str],
) -> dict[str, GenerationHistoryMeta]:
"""批量回填历史列表模块上下文,避免逐条链式查询。"""
ids = _unique(chat_task_ids)
if not ids:
return {}
if not is_generation_history_module_source(source):
return {chat_task_id: build_empty_history_meta(source) for chat_task_id in ids}
step_link_map = await _load_step_link_map(db, chat_task_ids=ids, source=source)
module_project_ids = [
step_info["module_project_id"]
for step_info in step_link_map.values()
if step_info.get("module_project_id")
]
project_map = await _load_project_map(db, module_project_ids=module_project_ids)
shot_segment_map: dict[str, _ShotSegmentInfo] = {}
if source == GenerationHistorySourceEnum.SHOT_REPLICATE:
shot_segment_map = await _load_shot_segment_map(db, module_project_ids=module_project_ids)
meta_map: dict[str, GenerationHistoryMeta] = {}
for chat_task_id in ids:
step_info = step_link_map.get(chat_task_id)
module_project_id = step_info.get("module_project_id") if step_info else None
project_info = project_map.get(module_project_id) if module_project_id else None
shot_info = shot_segment_map.get(module_project_id) if module_project_id else None
meta_map[chat_task_id] = _merge_meta(
source=source,
step_info=step_info,
project_info=project_info,
shot_info=shot_info,
)
return meta_map