AI CHAT对话降序获取升序输出,追加历史记录接口生成记录模块

This commit is contained in:
2026-05-27 16:34:22 +08:00
parent c17d467f59
commit 56b9bdaef0
4 changed files with 404 additions and 9 deletions
+2
View File
@@ -160,3 +160,5 @@ pnpm-debug.log*
.vite/
.next/
.nuxt/
*.zip
*.bat
+33 -3
View File
@@ -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-DDgen_type 不合法",
"description": "参数错误,例如 generated_date 格式不是 YYYY-MM-DDgen_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,
)
+138 -4
View File
@@ -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"
),
)
@@ -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)