home页生成记录API 补追文件

This commit is contained in:
2026-06-26 15:43:43 +08:00
parent 7d4eaebb79
commit 6ce6c23b55
4 changed files with 687 additions and 0 deletions
@@ -0,0 +1,104 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.enums.recent_generation import RecentGenerationModuleEnum
from app.models.user import User
from app.schemas.recent_generation import RecentGenerationGroupOut
from app.services.recent_generation_service import (
DEFAULT_RECENT_GENERATION_LIMIT,
MAX_RECENT_GENERATION_LIMIT,
list_recent_generations,
)
router = APIRouter(
prefix="/recent-generations",
tags=["recent-generations"],
)
@router.get(
"",
response_model=RecentGenerationGroupOut,
summary="获取当前用户各模块最近生成记录",
description=(
"获取当前登录用户在多个生成模块下最近生成成功的图片/视频记录。"
"返回结构固定为 project、chat_ai、hot_opening_replicate、shot_replicate 四个数组。"
"modules 不传时查询全部模块;modules 可重复传参指定一个或多个模块,例如 "
"?modules=project&modules=chat_ai。"
"limit 表示每个模块最多返回多少条,默认 5 条,最大 100 条。"
"接口只查询和返回展示所需轻量字段,不返回 prompt、engine_snapshot、provider_response_json 等大字段。"
),
responses={
200: {
"description": "查询成功,固定返回四个模块数组;没有数据的模块返回空数组。",
"content": {
"application/json": {
"example": {
"project": [],
"chat_ai": [],
"hot_opening_replicate": [],
"shot_replicate": [
{
"generated_time": "2026-06-26T14:30:00",
"result_url": "https://example.com/generate/video/demo.mp4?exp=1780000000&sign=xxxx",
"cover_url": "https://example.com/generate/cover/demo.jpg?exp=1780000000&sign=xxxx",
"module": "shot_replicate",
"shot_task_set_id": "0019ef0000000000001",
"shot_segment_id": "0019ef0000000000002",
"module_project_id": "0019ef0000000000003",
"module_step_id": "0019ef0000000000004",
"generation_id": "0019ef0000000000005",
"resource_type": "video",
}
],
}
}
},
},
401: {"description": "未登录或 Token 无效"},
403: {"description": "账号需要先设置登录密码或无权限"},
422: {"description": "参数校验失败,例如 limit 超出范围或 modules 枚举值非法"},
},
)
async def get_recent_generations(
limit: Annotated[
int,
Query(
ge=1,
le=MAX_RECENT_GENERATION_LIMIT,
description=(
"每个模块返回的最近生成记录数量,默认 5,最大 100。"
"例如 limit=10 表示 project/chat_ai/hot_opening_replicate/shot_replicate 每个模块最多返回 10 条。"
),
examples=[DEFAULT_RECENT_GENERATION_LIMIT],
),
] = DEFAULT_RECENT_GENERATION_LIMIT,
modules: Annotated[
list[RecentGenerationModuleEnum] | None,
Query(
description=(
"模块枚举,可不传或重复传参。"
"不传表示查询全部模块。"
"可选值:"
"project=项目生成 GenerationRecord"
"chat_ai=AI创作 ChatGenerationTask.generation_mode=chatapi_async"
"hot_opening_replicate=爆款开头复刻 ChatGenerationTask.generation_mode=hot_opening_replicate"
"shot_replicate=拆镜复刻 ChatGenerationTask.generation_mode=shot_replicate。"
),
examples=[["project", "chat_ai"]],
),
] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> RecentGenerationGroupOut:
return await list_recent_generations(
db,
user_id=current_user.id,
modules=modules,
limit=limit,
)
@@ -0,0 +1,57 @@
from __future__ import annotations
from enum import StrEnum
from app.enums.generation_task import ChatGenerationTaskStatus, GenerationMode
class RecentGenerationModuleEnum(StrEnum):
"""最近生成记录接口支持的模块分组枚举。"""
PROJECT = "project"
CHAT_AI = "chat_ai"
HOT_OPENING_REPLICATE = "hot_opening_replicate"
SHOT_REPLICATE = "shot_replicate"
class RecentGenerationResourceTypeEnum(StrEnum):
"""最近生成记录接口返回的资源类型枚举。"""
IMAGE = "image"
VIDEO = "video"
RECENT_GENERATION_ALL_MODULES: tuple[RecentGenerationModuleEnum, ...] = (
RecentGenerationModuleEnum.PROJECT,
RecentGenerationModuleEnum.CHAT_AI,
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE,
RecentGenerationModuleEnum.SHOT_REPLICATE,
)
"""最近生成记录接口默认查询的全部模块。"""
RECENT_GENERATION_CHAT_TASK_MODULES: tuple[RecentGenerationModuleEnum, ...] = (
RecentGenerationModuleEnum.CHAT_AI,
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE,
RecentGenerationModuleEnum.SHOT_REPLICATE,
)
"""来自 chat_generation_tasks 表的模块集合。"""
RECENT_GENERATION_MODULE_TO_TASK_MODE: dict[RecentGenerationModuleEnum, GenerationMode] = {
RecentGenerationModuleEnum.CHAT_AI: GenerationMode.CHATAPI_ASYNC,
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE: GenerationMode.HOT_OPENING_REPLICATE,
RecentGenerationModuleEnum.SHOT_REPLICATE: GenerationMode.SHOT_REPLICATE,
}
"""最近生成记录模块枚举到 ChatGenerationTask.generation_mode 的映射。"""
RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE: dict[str, RecentGenerationModuleEnum] = {
task_mode.value: module
for module, task_mode in RECENT_GENERATION_MODULE_TO_TASK_MODE.items()
}
"""ChatGenerationTask.generation_mode 字符串值到最近生成记录模块枚举的映射。"""
RECENT_GENERATION_COMPLETED_STATUS = ChatGenerationTaskStatus.COMPLETED.value
"""最近生成记录只展示生成成功的数据,状态值与 ChatGenerationTaskStatus.COMPLETED 保持一致。"""
@@ -0,0 +1,119 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
from app.enums.recent_generation import RecentGenerationModuleEnum, RecentGenerationResourceTypeEnum
from app.schemas.common import NaiveDatetimeOptional
class RecentGenerationItemOut(BaseModel):
"""最近生成记录响应项。"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"generated_time": "2026-06-26T14:30:00",
"result_url": "https://example.com/generate/video/demo.mp4?exp=1780000000&sign=xxxx",
"cover_url": "https://example.com/generate/cover/demo.jpg?exp=1780000000&sign=xxxx",
"module": "shot_replicate",
"shot_task_set_id": "0019ef0000000000001",
"shot_segment_id": "0019ef0000000000002",
"module_project_id": "0019ef0000000000003",
"module_step_id": "0019ef0000000000004",
"generation_id": "0019ef0000000000005",
"resource_type": "video",
}
}
)
generated_time: NaiveDatetimeOptional = Field(
None,
description="生成完成时间。优先使用 generated_at;历史数据 generated_at 为空时回退 updated_at,再回退 created_at。",
)
result_url: str | None = Field(
None,
description="生成结果链接。resource_type=image 时为图片链接;resource_type=video 时为视频链接。返回前会按项目资源签名规则追加 exp/sign。",
)
cover_url: str | None = Field(
None,
description="视频封面链接。仅视频资源通常有值;图片资源或历史无封面数据时返回 null。返回前会按项目资源签名规则追加 exp/sign。",
)
module: RecentGenerationModuleEnum = Field(
...,
description=(
"数据模块枚举:"
"project=项目生成 GenerationRecord"
"chat_ai=AI创作 ChatGenerationTask.generation_mode=chatapi_async"
"hot_opening_replicate=爆款开头复刻 ChatGenerationTask.generation_mode=hot_opening_replicate"
"shot_replicate=拆镜复刻 ChatGenerationTask.generation_mode=shot_replicate。"
),
)
shot_task_set_id: str | None = Field(
None,
description="关联拆镜总任务ID。仅 shot_replicate 模块可能有值,来源 shot_replicate_segments.task_set_id;其他模块返回 null。",
)
shot_segment_id: str | None = Field(
None,
description="关联拆镜片段ID。仅 shot_replicate 模块可能有值,来源 shot_replicate_segments.id;其他模块返回 null。",
)
module_project_id: str | None = Field(
None,
description="通用模块项目ID。hot_opening_replicate/shot_replicate 模块可能有值,来源 module_generation_steps.project_idproject/chat_ai 返回 null。",
)
module_step_id: str | None = Field(
None,
description="通用模块步骤ID。hot_opening_replicate/shot_replicate 模块可能有值,来源 module_generation_steps.idproject/chat_ai 返回 null。",
)
generation_id: str = Field(
...,
description="生成ID。project 模块为 generation_records.idchat_ai/hot_opening_replicate/shot_replicate 模块为 chat_generation_tasks.id。",
)
resource_type: RecentGenerationResourceTypeEnum = Field(
...,
description="资源类型枚举:image=图片资源;video=视频资源。前端可据此决定预览组件。",
)
class RecentGenerationGroupOut(BaseModel):
"""最近生成记录固定分组响应。"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"project": [],
"chat_ai": [
{
"generated_time": "2026-06-26T14:30:00",
"result_url": "https://example.com/generate/image/demo.png?exp=1780000000&sign=xxxx",
"cover_url": None,
"module": "chat_ai",
"shot_task_set_id": None,
"shot_segment_id": None,
"module_project_id": None,
"module_step_id": None,
"generation_id": "0019ef0000000000010",
"resource_type": "image",
}
],
"hot_opening_replicate": [],
"shot_replicate": [],
}
}
)
project: list[RecentGenerationItemOut] = Field(
default_factory=list,
description="项目生成最近记录数组,来源 generation_records。未查询该模块或无数据时返回空数组。",
)
chat_ai: list[RecentGenerationItemOut] = Field(
default_factory=list,
description="AI创作最近记录数组,来源 chat_generation_tasks,条件 generation_mode=chatapi_async。未查询该模块或无数据时返回空数组。",
)
hot_opening_replicate: list[RecentGenerationItemOut] = Field(
default_factory=list,
description="爆款开头复刻最近记录数组,来源 chat_generation_tasks,条件 generation_mode=hot_opening_replicate。未查询该模块或无数据时返回空数组。",
)
shot_replicate: list[RecentGenerationItemOut] = Field(
default_factory=list,
description="拆镜复刻最近记录数组,来源 chat_generation_tasks,条件 generation_mode=shot_replicate。未查询该模块或无数据时返回空数组。",
)
@@ -0,0 +1,407 @@
from __future__ import annotations
from collections.abc import Iterable
from datetime import datetime
from typing import Any, TypedDict
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.generation_task import GenerationType
from app.enums.recent_generation import (
RECENT_GENERATION_ALL_MODULES,
RECENT_GENERATION_CHAT_TASK_MODULES,
RECENT_GENERATION_COMPLETED_STATUS,
RECENT_GENERATION_MODULE_TO_TASK_MODE,
RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE,
RecentGenerationModuleEnum,
RecentGenerationResourceTypeEnum,
)
from app.models.chat_generation_task import ChatGenerationTask
from app.models.generation_record import GenerationRecord
from app.models.module_generation_step import ModuleGenerationStep
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.schemas.recent_generation import RecentGenerationGroupOut, RecentGenerationItemOut
from app.services.resource_signed_url_service import build_resource_signed_url
DEFAULT_RECENT_GENERATION_LIMIT = 5
MAX_RECENT_GENERATION_LIMIT = 100
class _StepLinkInfo(TypedDict):
module_project_id: str | None
module_step_id: str | None
module: str | None
class _ShotLinkInfo(TypedDict):
shot_task_set_id: str | None
shot_segment_id: str | None
def _normalize_limit(limit: int | None) -> int:
if limit is None:
return DEFAULT_RECENT_GENERATION_LIMIT
return min(max(int(limit), 1), MAX_RECENT_GENERATION_LIMIT)
def _normalize_modules(
modules: Iterable[RecentGenerationModuleEnum] | None,
) -> list[RecentGenerationModuleEnum]:
if not modules:
return list(RECENT_GENERATION_ALL_MODULES)
normalized: list[RecentGenerationModuleEnum] = []
seen: set[RecentGenerationModuleEnum] = set()
for module in modules:
module_enum = RecentGenerationModuleEnum(module)
if module_enum not in seen:
normalized.append(module_enum)
seen.add(module_enum)
return normalized
def _has_url(column) -> Any:
return and_(column.is_not(None), column != "")
def _detect_resource_type(
gen_type: str | None,
image_url: str | None,
video_url: str | None,
) -> RecentGenerationResourceTypeEnum:
gen_type_value = (gen_type or "").strip().lower()
if gen_type_value == GenerationType.IMAGE.value and image_url:
return RecentGenerationResourceTypeEnum.IMAGE
if gen_type_value == GenerationType.VIDEO.value and video_url:
return RecentGenerationResourceTypeEnum.VIDEO
if video_url:
return RecentGenerationResourceTypeEnum.VIDEO
return RecentGenerationResourceTypeEnum.IMAGE
def _sign_url(url: str | None) -> str | None:
if not url:
return None
return build_resource_signed_url(url)
def _build_item(
*,
generation_id: str,
module: RecentGenerationModuleEnum,
gen_type: str | None,
image_url: str | None,
video_url: str | None,
video_cover_url: str | None,
generated_time: datetime | None,
step_info: _StepLinkInfo | None = None,
shot_info: _ShotLinkInfo | None = None,
) -> RecentGenerationItemOut:
resource_type = _detect_resource_type(
gen_type=gen_type,
image_url=image_url,
video_url=video_url,
)
raw_result_url = video_url if resource_type == RecentGenerationResourceTypeEnum.VIDEO else image_url
raw_cover_url = video_cover_url if resource_type == RecentGenerationResourceTypeEnum.VIDEO else None
return RecentGenerationItemOut(
generated_time=generated_time,
result_url=_sign_url(raw_result_url),
cover_url=_sign_url(raw_cover_url),
module=module,
shot_task_set_id=shot_info["shot_task_set_id"] if shot_info else None,
shot_segment_id=shot_info["shot_segment_id"] if shot_info else None,
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,
generation_id=generation_id,
resource_type=resource_type,
)
async def _list_project_recent_items(
db: AsyncSession,
*,
user_id: str,
limit: int,
) -> list[RecentGenerationItemOut]:
generated_time_expr = func.coalesce(
GenerationRecord.generated_at,
GenerationRecord.updated_at,
GenerationRecord.created_at,
).label("generated_time")
stmt = (
select(
GenerationRecord.id.label("generation_id"),
GenerationRecord.gen_type.label("gen_type"),
GenerationRecord.image_url.label("image_url"),
GenerationRecord.video_url.label("video_url"),
GenerationRecord.video_cover_url.label("video_cover_url"),
generated_time_expr,
)
.where(
GenerationRecord.user_id == user_id,
GenerationRecord.deleted_at.is_(None),
GenerationRecord.status == RECENT_GENERATION_COMPLETED_STATUS,
or_(_has_url(GenerationRecord.image_url), _has_url(GenerationRecord.video_url)),
)
.order_by(generated_time_expr.desc(), GenerationRecord.created_at.desc())
.limit(limit)
)
rows = (await db.execute(stmt)).mappings().all()
return [
_build_item(
generation_id=row["generation_id"],
module=RecentGenerationModuleEnum.PROJECT,
gen_type=row["gen_type"],
image_url=row["image_url"],
video_url=row["video_url"],
video_cover_url=row["video_cover_url"],
generated_time=row["generated_time"],
)
for row in rows
]
async def _list_chat_task_recent_rows(
db: AsyncSession,
*,
user_id: str,
modules: list[RecentGenerationModuleEnum],
limit: int,
) -> list[dict[str, Any]]:
task_mode_values = [
RECENT_GENERATION_MODULE_TO_TASK_MODE[module].value
for module in modules
if module in RECENT_GENERATION_CHAT_TASK_MODULES
]
if not task_mode_values:
return []
generated_time_expr = func.coalesce(
ChatGenerationTask.generated_at,
ChatGenerationTask.updated_at,
ChatGenerationTask.created_at,
)
ranked_subquery = (
select(
ChatGenerationTask.id.label("generation_id"),
ChatGenerationTask.generation_mode.label("generation_mode"),
ChatGenerationTask.gen_type.label("gen_type"),
ChatGenerationTask.image_url.label("image_url"),
ChatGenerationTask.video_url.label("video_url"),
ChatGenerationTask.video_cover_url.label("video_cover_url"),
generated_time_expr.label("generated_time"),
func.row_number()
.over(
partition_by=ChatGenerationTask.generation_mode,
order_by=(generated_time_expr.desc(), ChatGenerationTask.created_at.desc()),
)
.label("row_num"),
)
.where(
ChatGenerationTask.user_id == user_id,
ChatGenerationTask.deleted_at.is_(None),
ChatGenerationTask.status == RECENT_GENERATION_COMPLETED_STATUS,
ChatGenerationTask.generation_mode.in_(task_mode_values),
or_(_has_url(ChatGenerationTask.image_url), _has_url(ChatGenerationTask.video_url)),
)
.subquery()
)
stmt = (
select(ranked_subquery)
.where(ranked_subquery.c.row_num <= limit)
.order_by(ranked_subquery.c.generation_mode.asc(), ranked_subquery.c.generated_time.desc())
)
return [dict(row) for row in (await db.execute(stmt)).mappings().all()]
async def _load_step_link_map(
db: AsyncSession,
*,
chat_task_ids: list[str],
) -> dict[str, _StepLinkInfo]:
if not chat_task_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.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_(chat_task_ids),
ModuleGenerationStep.module.in_(
[
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE.value,
RecentGenerationModuleEnum.SHOT_REPLICATE.value,
]
),
)
.order_by(
ModuleGenerationStep.chat_task_id.asc(),
ModuleGenerationStep.is_current.desc(),
ModuleGenerationStep.updated_at.desc(),
)
)
link_map: dict[str, _StepLinkInfo] = {}
rows = (await db.execute(stmt)).mappings().all()
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": row["module"],
}
return link_map
async def _load_shot_link_map(
db: AsyncSession,
*,
module_project_ids: list[str],
) -> dict[str, _ShotLinkInfo]:
if not module_project_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"),
)
.where(
ShotReplicateSegment.deleted_at.is_(None),
ShotReplicateSegment.module_project_id.in_(module_project_ids),
)
.order_by(ShotReplicateSegment.updated_at.desc())
)
link_map: dict[str, _ShotLinkInfo] = {}
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 link_map:
continue
link_map[module_project_id] = {
"shot_task_set_id": row["shot_task_set_id"],
"shot_segment_id": row["shot_segment_id"],
}
return link_map
async def _build_chat_task_group_items(
db: AsyncSession,
*,
rows: list[dict[str, Any]],
) -> dict[RecentGenerationModuleEnum, list[RecentGenerationItemOut]]:
grouped: dict[RecentGenerationModuleEnum, list[RecentGenerationItemOut]] = {
RecentGenerationModuleEnum.CHAT_AI: [],
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE: [],
RecentGenerationModuleEnum.SHOT_REPLICATE: [],
}
if not rows:
return grouped
module_task_rows: list[dict[str, Any]] = []
module_chat_task_ids: list[str] = []
for row in rows:
module = RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE.get(row["generation_mode"])
if module in (
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE,
RecentGenerationModuleEnum.SHOT_REPLICATE,
):
module_task_rows.append(row)
module_chat_task_ids.append(row["generation_id"])
step_link_map = await _load_step_link_map(db, chat_task_ids=module_chat_task_ids)
shot_project_ids = [
step_info["module_project_id"]
for row in module_task_rows
if (step_info := step_link_map.get(row["generation_id"]))
and step_info.get("module") == RecentGenerationModuleEnum.SHOT_REPLICATE.value
and step_info.get("module_project_id")
]
shot_link_map = await _load_shot_link_map(db, module_project_ids=shot_project_ids)
for row in rows:
module = RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE.get(row["generation_mode"])
if not module:
continue
step_info = step_link_map.get(row["generation_id"])
shot_info = None
if module == RecentGenerationModuleEnum.SHOT_REPLICATE and step_info:
module_project_id = step_info.get("module_project_id")
if module_project_id:
shot_info = shot_link_map.get(module_project_id)
grouped[module].append(
_build_item(
generation_id=row["generation_id"],
module=module,
gen_type=row["gen_type"],
image_url=row["image_url"],
video_url=row["video_url"],
video_cover_url=row["video_cover_url"],
generated_time=row["generated_time"],
step_info=step_info,
shot_info=shot_info,
)
)
return grouped
async def list_recent_generations(
db: AsyncSession,
*,
user_id: str,
modules: Iterable[RecentGenerationModuleEnum] | None = None,
limit: int | None = None,
) -> RecentGenerationGroupOut:
"""获取当前用户各模块最近生成成功的图片/视频记录。"""
normalized_limit = _normalize_limit(limit)
normalized_modules = _normalize_modules(modules)
response = RecentGenerationGroupOut()
if RecentGenerationModuleEnum.PROJECT in normalized_modules:
response.project = await _list_project_recent_items(
db,
user_id=user_id,
limit=normalized_limit,
)
chat_modules = [module for module in normalized_modules if module in RECENT_GENERATION_CHAT_TASK_MODULES]
if chat_modules:
chat_rows = await _list_chat_task_recent_rows(
db,
user_id=user_id,
modules=chat_modules,
limit=normalized_limit,
)
chat_grouped = await _build_chat_task_group_items(db, rows=chat_rows)
response.chat_ai = chat_grouped[RecentGenerationModuleEnum.CHAT_AI]
response.hot_opening_replicate = chat_grouped[RecentGenerationModuleEnum.HOT_OPENING_REPLICATE]
response.shot_replicate = chat_grouped[RecentGenerationModuleEnum.SHOT_REPLICATE]
return response