diff --git a/video-gen-api/.gitignore b/video-gen-api/.gitignore index 35e593ed..c2a36060 100644 --- a/video-gen-api/.gitignore +++ b/video-gen-api/.gitignore @@ -159,4 +159,6 @@ yarn-error.log* pnpm-debug.log* .vite/ .next/ -.nuxt/ \ No newline at end of file +.nuxt/ +*.zip +*.bat \ No newline at end of file diff --git a/video-gen-api/app/api/v1/generation_ai.py b/video-gen-api/app/api/v1/generation_ai.py index b30ea560..fe18a989 100644 --- a/video-gen-api/app/api/v1/generation_ai.py +++ b/video-gen-api/app/api/v1/generation_ai.py @@ -140,7 +140,15 @@ async def list_tasks( page, page_size, ) - return GenerationAITaskListOut(total=total, items=[record_to_out(i) for i in items]) + + # ====================== 在这里加排序(最新在前)====================== + # 按 created_at 降序(没有则用 id 降序) + items_sorted = sorted( + items, + key=lambda x: x.created_at if x.created_at is not None else x.id, + reverse=False # 降序 + ) + return GenerationAITaskListOut(total=total, items=[record_to_out(i) for i in items_sorted]) @router.get( @@ -149,6 +157,8 @@ async def list_tasks( summary="获取AI生成历史日期分组", description=( "按生成完成日期倒序返回当前用户的AI生成历史记录。" + "默认查询 chat_generation_tasks / ChatGenerationTask 新任务历史。" + "当 history_source=generation_record 时,查询 generation_records / GenerationRecord 旧历史。" "该接口只返回生成成功的任务,即 status=completed 且 generated_at 不为空的数据。" "必须通过 gen_type 区分图片和视频。" "分页对象是生成日期,不是单条记录。" @@ -160,7 +170,7 @@ async def list_tasks( "description": "查询成功,返回按生成日期分组的历史记录", }, 400: { - "description": "参数错误,例如 gen_type 不是 image 或 video", + "description": "参数错误,例如 gen_type 不是 image 或 video,或 history_source 不支持", }, 401: { "description": "未登录或 Token 无效", @@ -186,6 +196,14 @@ async def list_history_grouped_days( description="每页返回的生成日期数量,范围 1~10,最大只能获取10天", examples=[10], ), + history_source: str | None = Query( + None, + description=( + "历史数据来源。默认不传或传 chat_task 查询 chat_generation_tasks / ChatGenerationTask;" + "传 generation_record 查询 generation_records / GenerationRecord 旧历史数据" + ), + examples=["generation_record"], + ), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -195,6 +213,7 @@ async def list_history_grouped_days( gen_type=gen_type, page=page, page_size=page_size, + history_source=history_source, ) @@ -204,6 +223,8 @@ async def list_history_grouped_days( summary="获取指定日期下的AI生成历史分页", description=( "获取某一个生成日期下的生成成功记录分页。" + "默认查询 chat_generation_tasks / ChatGenerationTask 新任务历史。" + "当 history_source=generation_record 时,查询 generation_records / GenerationRecord 旧历史。" "该接口用于前端在历史分组列表中继续加载某一天的后续记录。" "例如 /history 接口中某一天 total=18,但 items 只返回前10条," "则前端可以调用本接口 page=2&page_size=10 获取该日期下剩余记录。" @@ -214,7 +235,7 @@ async def list_history_grouped_days( "description": "查询成功,返回指定日期下的历史记录分页", }, 400: { - "description": "参数错误,例如 generated_date 格式不是 YYYY-MM-DD,或 gen_type 不合法", + "description": "参数错误,例如 generated_date 格式不是 YYYY-MM-DD,gen_type 不合法,或 history_source 不支持", }, 401: { "description": "未登录或 Token 无效", @@ -245,6 +266,14 @@ async def list_history_day_items( description="当前日期下每页返回的生成记录数量,范围 1~100", examples=[10], ), + history_source: str | None = Query( + None, + description=( + "历史数据来源。默认不传或传 chat_task 查询 chat_generation_tasks / ChatGenerationTask;" + "传 generation_record 查询 generation_records / GenerationRecord 旧历史数据" + ), + examples=["generation_record"], + ), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -255,6 +284,7 @@ async def list_history_day_items( generated_date=generated_date, page=page, page_size=page_size, + history_source=history_source, ) diff --git a/video-gen-api/app/schemas/generation_ai.py b/video-gen-api/app/schemas/generation_ai.py index a76456dc..b5321788 100644 --- a/video-gen-api/app/schemas/generation_ai.py +++ b/video-gen-api/app/schemas/generation_ai.py @@ -1,3 +1,5 @@ +from typing import Annotated, Literal + from pydantic import BaseModel, ConfigDict, Field from app.schemas.common import NaiveDatetimeOptional @@ -157,6 +159,7 @@ class GenerationAITaskOut(BaseModel): json_schema_extra={ "example": { "id": "0019e0a44895b6d837d", + "source_type": "chat_task", "project_id": None, "gen_type": "image", "generation_mode": "chatapi_async", @@ -204,6 +207,10 @@ class GenerationAITaskOut(BaseModel): ) id: str = Field(..., description="生成任务ID") + source_type: Literal["chat_task"] = Field( + "chat_task", + description="历史记录来源。ChatGenerationTask 新任务历史固定为 chat_task", + ) project_id: str | None = Field( None, description="项目ID。当前 /generation-ai 任务不绑定项目,通常为 null", @@ -274,6 +281,7 @@ class GenerationAITaskListOut(BaseModel): "items": [ { "id": "0019e0a44895b6d837d", + "source_type": "chat_task", "project_id": None, "gen_type": "image", "generation_mode": "chatapi_async", @@ -338,6 +346,124 @@ class GenerationAIRetryOut(BaseModel): message: str = Field(..., description="操作结果提示信息") +class GenerationAIRecordHistoryItemOut(BaseModel): + """旧 generation_records 历史记录详情响应体。""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "id": "0019e0a44895b6d837d", + "source_type": "generation_record", + "project_id": "project_xxx", + "project_name": "默认项目", + "gen_type": "image", + "generation_mode": "generation_record", + "pipeline_stage": None, + "status": "completed", + "original_prompt": "生成一张赛博朋克风格的城市夜景", + "optimized_prompt": "赛博朋克城市夜景,霓虹灯,电影感,高细节", + "duration": None, + "aspect_ratio": None, + "resolution": None, + "image_size": "2K", + "image_proportion": "1:1", + "image_px": "2048x2048", + "references": None, + "media_references": None, + "provider_task_id": "provider_task_xxx", + "seedance_task_id": "provider_task_xxx", + "remote_result_url": None, + "image_url": "https://example.com/result.png", + "video_url": None, + "engine_id": None, + "engine_snapshot": None, + "credits_cost": 10.0, + "text_credits_cost": 1.0, + "text_tokens_used": 100, + "image_tokens_used": 0, + "video_tokens_used": 0, + "retry_count": 0, + "poll_count": 0, + "error_message": None, + "created_at": "2026-05-27T10:12:00", + "generated_at": "2026-05-27T10:15:30", + } + } + ) + + id: str = Field(..., description="旧生成记录ID") + source_type: Literal["generation_record"] = Field( + "generation_record", + description="历史记录来源固定为 generation_record,用于和 chat_task 历史区分", + ) + project_id: str | None = Field(None, description="旧项目ID,来源于 generation_records.project_id") + project_name: str | None = Field(None, description="旧项目名称,来源于 projects.name;项目不存在时为空") + gen_type: str = Field(..., description="生成类型:image=图片,video=视频") + generation_mode: str | None = Field( + "generation_record", + description="兼容新历史结构的生成模式字段。旧表历史固定返回 generation_record", + ) + pipeline_stage: str | None = Field( + None, + description="兼容新历史结构的流水线阶段字段。旧 generation_records 无该字段,固定为 null", + ) + status: str = Field(..., description="记录状态,例如 completed=已完成,failed=失败") + original_prompt: str = Field(..., description="用户原始提示词") + optimized_prompt: str | None = Field(None, description="优化后的提示词,可能为空") + duration: int | None = Field(None, description="视频时长,单位秒。图片记录通常为空") + aspect_ratio: str | None = Field(None, description="视频比例,例如 16:9。图片记录通常为空") + resolution: str | None = Field(None, description="视频分辨率,例如 480p。图片记录通常为空") + image_size: str | None = Field(None, description="图片分辨率档位,例如 2K") + image_proportion: str | None = Field(None, description="图片比例,例如 1:1") + image_px: str | None = Field(None, description="图片像素尺寸,例如 2048x2048") + references: list[dict] | None = Field( + None, + description="旧接口字段名,来源于 generation_records.media_references 解析后的参考素材列表", + ) + media_references: list[dict] | None = Field( + None, + description="兼容新历史结构字段名,和 references 内容一致", + ) + provider_task_id: str | None = Field(None, description="第三方服务商任务ID,旧表使用 seedance_task_id 兼容填充") + seedance_task_id: str | None = Field(None, description="旧服务命名的第三方任务ID字段") + remote_result_url: str | None = Field( + None, + description="第三方远程结果地址。旧 generation_records 未保存该字段,固定为 null", + ) + image_url: str | None = Field(None, description="最终图片地址。图片记录完成后通常有值") + video_url: str | None = Field(None, description="最终视频地址。视频记录完成后通常有值") + engine_id: str | None = Field( + None, + description="兼容新历史结构的引擎ID字段。旧 generation_records 未保存该字段,固定为 null", + ) + engine_snapshot: dict | None = Field( + None, + description="兼容新历史结构的引擎快照字段。旧 generation_records 未保存该字段,固定为 null", + ) + credits_cost: float = Field(0.0, description="本次记录总消耗积分") + text_credits_cost: float = Field(0.0, description="文本优化或文本处理消耗积分") + text_tokens_used: int = Field(0, description="文本 token 使用量") + image_tokens_used: int = Field(0, description="图片 token 使用量") + video_tokens_used: int = Field(0, description="视频 token 使用量") + retry_count: int = Field( + 0, + description="兼容新历史结构的重试次数字段。旧 generation_records 未保存该字段,固定为 0", + ) + poll_count: int = Field( + 0, + description="兼容新历史结构的轮询次数字段。旧 generation_records 未保存该字段,固定为 0", + ) + error_message: str | None = Field(None, description="错误信息。成功记录一般为 null") + created_at: NaiveDatetimeOptional = Field(None, description="记录创建时间") + generated_at: NaiveDatetimeOptional = Field(None, description="生成完成时间") + + +GenerationAIHistoryItemOut = Annotated[ + GenerationAITaskOut | GenerationAIRecordHistoryItemOut, + Field(discriminator="source_type"), +] + + class GenerationAIHistoryDayGroupOut(BaseModel): """AI生成历史按天分组响应项。""" @@ -353,9 +479,13 @@ class GenerationAIHistoryDayGroupOut(BaseModel): generated_date: str = Field(..., description="生成日期,格式:YYYY-MM-DD") total: int = Field(..., description="当前生成日期下的生成成功记录总数") - items: list[GenerationAITaskOut] = Field( + items: list[GenerationAIHistoryItemOut] = Field( default_factory=list, - description="当前生成日期下倒序前10条生成记录详情", + description=( + "当前生成日期下倒序前10条生成记录详情。" + "默认 history_source=chat_task 时 item 为 GenerationAITaskOut;" + "history_source=generation_record 时 item 为 GenerationAIRecordHistoryItemOut" + ), ) @@ -412,7 +542,11 @@ class GenerationAIHistoryDayItemsOut(BaseModel): total: int = Field(..., description="当前日期下的生成成功记录总数") page: int = Field(..., description="当前日期下的记录分页页码") page_size: int = Field(..., description="当前日期下每页返回的生成记录数量") - items: list[GenerationAITaskOut] = Field( + items: list[GenerationAIHistoryItemOut] = Field( default_factory=list, - description="当前日期下的生成记录详情列表,按 generated_at 倒序排列", + description=( + "当前日期下的生成记录详情列表,按 generated_at 倒序排列。" + "默认 history_source=chat_task 时 item 为 GenerationAITaskOut;" + "history_source=generation_record 时 item 为 GenerationAIRecordHistoryItemOut" + ), ) \ No newline at end of file diff --git a/video-gen-api/app/services/generation_ai_service.py b/video-gen-api/app/services/generation_ai_service.py index ddf56a55..9e574b3e 100644 --- a/video-gen-api/app/services/generation_ai_service.py +++ b/video-gen-api/app/services/generation_ai_service.py @@ -10,10 +10,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.models.chat_generation_task import ChatGenerationTask +from app.models.generation_record import GenerationRecord +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.schemas.generation_ai import GenerationAITaskCreate, GenerationAITaskOut +from app.schemas.generation_ai import ( + GenerationAIRecordHistoryItemOut, + GenerationAITaskCreate, + GenerationAITaskOut, +) from app.services.generation_billing_service import charge_generation_media_by_params from app.utils.id_gen import generate_id @@ -344,6 +350,21 @@ def _normalize_history_gen_type(gen_type: str | None) -> str: return value + +def _normalize_history_source(history_source: str | None) -> str: + """Normalize history source query param. + + 默认保持原来的 chat_generation_tasks 历史;只有显式传 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_task 或 generation_record") + + def _history_day_to_str(value) -> str: if isinstance(value, datetime): return value.date().strftime("%Y-%m-%d") @@ -369,12 +390,198 @@ def _history_base_filters(user_id: str, gen_type: str): ] +def _generation_record_history_base_filters(user_id: str, gen_type: str): + return [ + GenerationRecord.user_id == user_id, + GenerationRecord.status == "completed", + GenerationRecord.gen_type == gen_type, + GenerationRecord.generated_at.is_not(None), + ] + + +def generation_record_to_history_out( + record: GenerationRecord, + project_name: str | None = None, +) -> GenerationAIRecordHistoryItemOut: + refs = _parse_json(record.media_references) + return GenerationAIRecordHistoryItemOut( + id=record.id, + source_type="generation_record", + project_id=record.project_id, + project_name=project_name, + gen_type=record.gen_type, + generation_mode="generation_record", + pipeline_stage=None, + status=record.status, + original_prompt=record.original_prompt, + # optimized_prompt=None, + duration=record.duration, + aspect_ratio=record.aspect_ratio, + resolution=record.resolution, + image_size=record.image_size, + image_proportion=record.image_proportion, + image_px=record.image_px, + references=refs, + media_references=refs, + provider_task_id=record.seedance_task_id, + seedance_task_id=record.seedance_task_id, + remote_result_url=None, + image_url=record.image_url, + video_url=record.video_url, + engine_id=None, + engine_snapshot=None, + credits_cost=record.credits_cost or 0.0, + text_credits_cost=record.text_credits_cost or 0.0, + text_tokens_used=record.text_tokens_used or 0, + image_tokens_used=record.image_tokens_used or 0, + video_tokens_used=record.video_tokens_used or 0, + retry_count=0, + poll_count=0, + error_message=record.error_message, + created_at=record.created_at, + generated_at=record.generated_at, + ) + + +async def list_generation_record_history_grouped_days( + db: AsyncSession, + user_id: str, + gen_type: str, + page: int, + page_size: int, +): + """ + 按生成日期倒序返回旧 generation_records 历史记录分组。 + + - 每页最多返回 10 个生成日期 + - 每个日期分组内最多返回倒序前 10 条旧记录 + - 只返回 completed 成功记录 + """ + gen_type = _normalize_history_gen_type(gen_type) + page = max(page, 1) + page_size = min(max(page_size, 1), HISTORY_DAY_PAGE_SIZE_MAX) + + filters = _generation_record_history_base_filters(user_id, gen_type) + day_expr = func.date(GenerationRecord.generated_at).label("generated_date") + + days_subquery = ( + select(day_expr) + .where(*filters) + .group_by(day_expr) + .subquery() + ) + + total_days = ( + await db.execute(select(func.count()).select_from(days_subquery)) + ).scalar_one() + + day_rows_result = await db.execute( + select( + day_expr, + func.count(GenerationRecord.id).label("total"), + ) + .where(*filters) + .group_by(day_expr) + .order_by(day_expr.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + day_rows = day_rows_result.all() + + groups = [] + for generated_day, day_total in day_rows: + item_result = await db.execute( + select(GenerationRecord, Project.name.label("project_name")) + .outerjoin(Project, GenerationRecord.project_id == Project.id) + .where( + *filters, + func.date(GenerationRecord.generated_at) == generated_day, + ) + .order_by(GenerationRecord.generated_at.desc(), GenerationRecord.created_at.desc()) + .limit(HISTORY_GROUP_ITEM_LIMIT) + ) + rows = item_result.all() + + groups.append( + { + "generated_date": _history_day_to_str(generated_day), + "total": int(day_total or 0), + "items": [ + generation_record_to_history_out(record, project_name) + for record, project_name in rows + ], + } + ) + + return { + "total_days": int(total_days or 0), + "page": page, + "page_size": page_size, + "groups": groups, + } + + +async def list_generation_record_history_day_items( + db: AsyncSession, + user_id: str, + gen_type: str, + generated_date: str, + page: int, + page_size: int, +): + """ + 获取旧 generation_records 指定生成日期下的历史记录分页。 + """ + gen_type = _normalize_history_gen_type(gen_type) + target_day = _parse_history_date(generated_date) + page = max(page, 1) + page_size = min(max(page_size, 1), 100) + + filters = _generation_record_history_base_filters(user_id, gen_type) + day_expr = func.date(GenerationRecord.generated_at) + + total = ( + await db.execute( + select(func.count(GenerationRecord.id)).where( + *filters, + day_expr == target_day, + ) + ) + ).scalar_one() + + result = await db.execute( + select(GenerationRecord, Project.name.label("project_name")) + .outerjoin(Project, GenerationRecord.project_id == Project.id) + .where( + *filters, + day_expr == target_day, + ) + .order_by(GenerationRecord.generated_at.desc(), GenerationRecord.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + + rows = result.all() + + return { + "generated_date": target_day.strftime("%Y-%m-%d"), + "total": int(total or 0), + "page": page, + "page_size": page_size, + "items": [ + generation_record_to_history_out(record, project_name) + for record, project_name in rows + ], + } + + async def list_generation_history_grouped_days( db: AsyncSession, user_id: str, gen_type: str, page: int, page_size: int, + history_source: str | None = None, ): """ 按生成日期倒序返回历史记录分组。 @@ -383,6 +590,16 @@ async def list_generation_history_grouped_days( - 每个日期分组内最多返回倒序前 10 条任务 - 只返回 completed 成功任务 """ + source = _normalize_history_source(history_source) + if source == "generation_record": + return await list_generation_record_history_grouped_days( + db=db, + user_id=user_id, + gen_type=gen_type, + page=page, + page_size=page_size, + ) + gen_type = _normalize_history_gen_type(gen_type) page = max(page, 1) page_size = min(max(page_size, 1), HISTORY_DAY_PAGE_SIZE_MAX) @@ -450,12 +667,24 @@ async def list_generation_history_day_items( generated_date: str, page: int, page_size: int, + history_source: str | None = None, ): """ 获取指定生成日期下的历史记录分页。 用于前端点击某一天后,继续加载该日期下的第 2 页、第 3 页数据。 """ + source = _normalize_history_source(history_source) + if source == "generation_record": + return await list_generation_record_history_day_items( + db=db, + user_id=user_id, + gen_type=gen_type, + generated_date=generated_date, + page=page, + page_size=page_size, + ) + gen_type = _normalize_history_gen_type(gen_type) target_day = _parse_history_date(generated_date) page = max(page, 1)