生成项目任务/chat任务软删|生成资源管控回收|生成资源token验签API预处理

This commit is contained in:
2026-06-01 11:10:09 +08:00
parent edef601f7c
commit dfbf51b5c0
26 changed files with 1296 additions and 63 deletions
+16 -7
View File
@@ -42,6 +42,7 @@ from app.services.credits import add_credits, deduct_credits
from app.services.notification import create_notification
from app.services.auth import hash_password, verify_password
from app.services.operation_log import log_operation
from app.services.resource_signed_url_service import build_resource_signed_url
from app.utils.id_gen import generate_id
from app.schemas.generation import GenerationType, ASPECT_RATIOS, RESOLUTIONS
@@ -884,9 +885,9 @@ async def get_stats(
total_users = (await db.execute(
select(func.count(User.id)).where(User.user_type == "frontend")
)).scalar() or 0
total_projects = (await db.execute(select(func.count(Project.id)))).scalar() or 0
total_projects = (await db.execute(select(func.count(Project.id)).where(Project.deleted_at.is_(None)))).scalar() or 0
total_generations = (
await db.execute(select(func.count(GenerationRecord.id)))
await db.execute(select(func.count(GenerationRecord.id)).where(GenerationRecord.deleted_at.is_(None)))
).scalar() or 0
total_revenue = (
await db.execute(
@@ -973,6 +974,7 @@ async def admin_list_generation_records(
select(GenerationRecord, User.username, Project.name)
.join(User, GenerationRecord.user_id == User.id)
.join(Project, GenerationRecord.project_id == Project.id)
.where(GenerationRecord.deleted_at.is_(None), Project.deleted_at.is_(None))
.order_by(GenerationRecord.created_at.desc())
)
if user_id:
@@ -981,7 +983,7 @@ async def admin_list_generation_records(
query = query.where(GenerationRecord.status == status)
# Count total
count_query = select(func.count(GenerationRecord.id))
count_query = select(func.count(GenerationRecord.id)).where(GenerationRecord.deleted_at.is_(None))
if user_id:
count_query = count_query.where(GenerationRecord.user_id == user_id)
if status:
@@ -1015,7 +1017,7 @@ async def admin_list_generation_records(
"aspect_ratio": record.aspect_ratio,
"resolution": record.resolution,
"status": record.status,
"video_url": record.video_url,
"video_url": build_resource_signed_url(record.video_url) if record.video_url else '',
"references": refs,
"credits_cost": record.credits_cost or 0,
"text_credits_cost": record.text_credits_cost or 0,
@@ -1028,7 +1030,7 @@ async def admin_list_generation_records(
# append img param
"gen_type": record.gen_type,
"image_size": record.image_size or '',
"image_url": record.image_url or '',
"image_url": build_resource_signed_url(record.image_url) if record.image_url else '',
"image_tokens_used": record.image_tokens_used or 0,
"image_proportion": record.image_proportion or '',
"image_px": record.image_px or '',
@@ -1046,7 +1048,10 @@ async def admin_update_generation_status(
):
"""Admin update generation record status (e.g., confirm/reject)."""
result = await db.execute(
select(GenerationRecord).where(GenerationRecord.id == record_id)
select(GenerationRecord).where(
GenerationRecord.id == record_id,
GenerationRecord.deleted_at.is_(None),
)
)
record = result.scalar_one_or_none()
if not record:
@@ -1080,7 +1085,11 @@ async def admin_generate_video(
result = await db.execute(
select(GenerationRecord, Project.name)
.join(Project, GenerationRecord.project_id == Project.id)
.where(GenerationRecord.id == record_id)
.where(
GenerationRecord.id == record_id,
GenerationRecord.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
)
row = result.first()
if not row:
+41 -5
View File
@@ -28,6 +28,11 @@ from app.schemas.generation import (
from app.services.credits import deduct_credits, calc_text_credits, calc_video_credits, calc_image_credits
from app.services.llm import optimize_prompt
from app.services.video_url import generate_temp_url, validate_and_get_record_id, get_video_stream_url
from app.services.resource_accounting_service import (
record_generation_record_generated_resource,
safe_file_size,
)
from app.services.resource_signed_url_service import build_resource_signed_url
from app.utils.id_gen import generate_id
from app.utils.exceptions import InsufficientCreditsError, RecordNotFoundError, InvalidStatusError
@@ -71,8 +76,8 @@ def _record_to_out(record: GenerationRecord, project_name: str) -> GenerationRec
image_proportion=record.image_proportion,
image_px=record.image_px,
status=record.status,
video_url=record.video_url,
image_url=record.image_url,
video_url=build_resource_signed_url(record.video_url) if record.video_url else '',
image_url=build_resource_signed_url(record.image_url) if record.image_url else '',
references=refs,
text_credits_cost=round(record.text_credits_cost or 0.00, 2),
# text_tokens_used=record.text_tokens_used or 0,
@@ -94,7 +99,11 @@ async def list_records(
query = (
select(GenerationRecord, Project.name)
.join(Project, GenerationRecord.project_id == Project.id)
.where(GenerationRecord.user_id == current_user.id)
.where(
GenerationRecord.user_id == current_user.id,
GenerationRecord.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
.order_by(GenerationRecord.created_at.desc())
)
if project_id:
@@ -133,6 +142,8 @@ async def optimize(
.join(Project, GenerationRecord.project_id == Project.id)
.where(
GenerationRecord.user_id == current_user.id,
GenerationRecord.deleted_at.is_(None),
Project.deleted_at.is_(None),
GenerationRecord.idempotency_key == req.idempotency_key,
GenerationRecord.gen_type == req.gen_type,
GenerationRecord.status == "prompt_optimized",
@@ -155,6 +166,7 @@ async def optimize(
select(Project).where(
Project.id == req.project_id,
Project.user_id == current_user.id,
Project.deleted_at.is_(None),
)
)
project = proj_result.scalar_one_or_none()
@@ -243,6 +255,8 @@ async def generate(
.where(
GenerationRecord.id == record_id,
GenerationRecord.user_id == current_user.id,
GenerationRecord.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
)
row = result.first()
@@ -326,6 +340,8 @@ async def retry_generation(
.where(
GenerationRecord.id == record_id,
GenerationRecord.user_id == current_user.id,
GenerationRecord.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
)
row = result.first()
@@ -378,6 +394,7 @@ async def update_prompt(
select(GenerationRecord).where(
GenerationRecord.id == record_id,
GenerationRecord.user_id == current_user.id,
GenerationRecord.deleted_at.is_(None),
)
)
record = result.scalar_one_or_none()
@@ -422,6 +439,7 @@ async def get_queue_status(
select(GenerationRecord).where(
GenerationRecord.id == record_id,
GenerationRecord.user_id == current_user.id,
GenerationRecord.deleted_at.is_(None),
)
)
record = result.scalar_one_or_none()
@@ -435,6 +453,7 @@ async def get_queue_status(
ahead_result = await db.execute(
select(func.count(GenerationRecord.id)).where(
GenerationRecord.status == "generating",
GenerationRecord.deleted_at.is_(None),
GenerationRecord.created_at < record.created_at,
)
)
@@ -461,7 +480,10 @@ async def seedance_callback(request: Request, db: AsyncSession = Depends(get_db)
return {"message": "ignored"}
result = await db.execute(
select(GenerationRecord).where(GenerationRecord.seedance_task_id == task_id)
select(GenerationRecord).where(
GenerationRecord.seedance_task_id == task_id,
GenerationRecord.deleted_at.is_(None),
)
)
record = result.scalar_one_or_none()
if not record:
@@ -470,19 +492,33 @@ async def seedance_callback(request: Request, db: AsyncSession = Depends(get_db)
if task_status == "succeeded":
remote_url = data.get("content", {}).get("video_url", "")
record.status = "completed"
storage_path = None
file_size_bytes = 0
# Download video to local storage
if settings.STORAGE_TYPE == "local" and remote_url:
try:
from app.services.video_gen import download_video
dest = os.path.join(settings.STORAGE_LOCAL_PATH, f"{record.id}.mp4")
await download_video(remote_url, dest)
record.video_url = f"/videos/{record.id}.mp4"
record.video_url = f"/generate/videos/{record.id}.mp4"
storage_path = dest
file_size_bytes = safe_file_size(dest)
except Exception as e:
logger.warning(f"Callback download failed, using remote URL: {e}")
record.video_url = remote_url
else:
record.video_url = remote_url
record.generated_at = datetime.now()
if record.video_url:
await record_generation_record_generated_resource(
db,
record,
resource_url=record.video_url,
storage_path=storage_path,
file_size_bytes=file_size_bytes,
remote_url=remote_url,
generated_at=record.generated_at,
)
# Extract video token usage from callback
usage = data.get("usage", {})
if usage:
+71
View File
@@ -1,3 +1,5 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,6 +12,7 @@ from app.schemas.generation_ai import (
GenerationAIHistoryDayItemsOut,
GenerationAIHistoryGroupedOut,
GenerationAIRetryOut,
GenerationAITaskDeleteOut,
GenerationAITaskCreate,
GenerationAITaskListOut,
GenerationAITaskOut,
@@ -21,6 +24,7 @@ from app.services.generation_ai_service import (
list_generation_history_day_items,
list_generation_history_grouped_days,
record_to_out,
soft_delete_chat_generation_task,
)
from app.services.generation_log_service import log_task_event
from app.tasks.celery_app import celery_app
@@ -389,6 +393,7 @@ async def get_task(
ChatGenerationTask.id == task_id,
ChatGenerationTask.user_id == current_user.id,
ChatGenerationTask.generation_mode == "chatapi_async",
ChatGenerationTask.deleted_at.is_(None),
)
)
task = result.scalar_one_or_none()
@@ -397,6 +402,71 @@ async def get_task(
return record_to_out(task)
@router.delete(
"/tasks/{task_id}",
response_model=GenerationAITaskDeleteOut,
summary="删除AI生成任务",
description=(
"软删除当前登录用户自己的AI生成任务。"
"该接口不会物理删除数据库记录和本地文件,只会设置 deleted_at,后续列表、详情、历史统计默认不再返回。"
"删除已完成任务时会联动软删 generated_resources 资源账本,并重新扣减用户有效资源空间统计。"
"如果任务仍处于 generating 生成中状态,接口会直接拦截,不允许删除。"
),
responses={
200: {
"description": "软删除成功,返回任务ID和本次释放的资源空间字节数",
},
400: {
"description": "任务正在生成中,暂不能删除",
},
401: {
"description": "未登录或 Token 无效",
},
404: {
"description": "任务不存在,或任务不属于当前用户,或任务已经被删除",
},
},
)
async def delete_task(
task_id: str = Path(
...,
description="需要删除的AI生成任务ID",
examples=["0019e0a44895b6d837d"],
),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ChatGenerationTask).where(
ChatGenerationTask.id == task_id,
ChatGenerationTask.user_id == current_user.id,
ChatGenerationTask.generation_mode == "chatapi_async",
ChatGenerationTask.deleted_at.is_(None),
)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if task.status == "generating":
raise HTTPException(status_code=400, detail="当前任务正在生成中,暂不能删除")
deleted_at = datetime.now(timezone.utc)
freed_size_bytes = await soft_delete_chat_generation_task(
db,
task=task,
deleted_at=deleted_at,
)
await db.flush()
return GenerationAITaskDeleteOut(
message="任务已删除",
task_id=task.id,
deleted=True,
freed_size_bytes=freed_size_bytes,
)
@router.post(
"/tasks/{task_id}/retry",
response_model=GenerationAIRetryOut,
@@ -443,6 +513,7 @@ async def retry_task(
ChatGenerationTask.id == task_id,
ChatGenerationTask.user_id == current_user.id,
ChatGenerationTask.generation_mode == "chatapi_async",
ChatGenerationTask.deleted_at.is_(None),
)
)
task = result.scalar_one_or_none()
+49 -8
View File
@@ -1,5 +1,7 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
@@ -7,6 +9,7 @@ from app.models.user import User
from app.models.project import Project
from app.models.generation_record import GenerationRecord
from app.schemas.project import ProjectCreate, ProjectOut
from app.services.resource_accounting_service import soft_delete_generation_record_resources
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/projects", tags=["projects"])
@@ -19,7 +22,10 @@ async def list_projects(
):
result = await db.execute(
select(Project)
.where(Project.user_id == current_user.id)
.where(
Project.user_id == current_user.id,
Project.deleted_at.is_(None),
)
.order_by(Project.created_at.desc())
)
return result.scalars().all()
@@ -52,17 +58,52 @@ async def delete_project(
select(Project).where(
Project.id == project_id,
Project.user_id == current_user.id,
Project.deleted_at.is_(None),
)
)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
# Cascade delete generation records
from sqlalchemy import delete
await db.execute(
delete(GenerationRecord).where(GenerationRecord.project_id == project_id)
generating_count = (
await db.execute(
select(func.count(GenerationRecord.id)).where(
GenerationRecord.project_id == project_id,
GenerationRecord.user_id == current_user.id,
GenerationRecord.status == "generating",
GenerationRecord.deleted_at.is_(None),
)
)
).scalar() or 0
if generating_count > 0:
raise HTTPException(status_code=400, detail="当前项目下存在生成中任务,暂不能删除")
records_result = await db.execute(
select(GenerationRecord).where(
GenerationRecord.project_id == project_id,
GenerationRecord.user_id == current_user.id,
GenerationRecord.deleted_at.is_(None),
)
)
await db.delete(project)
records = list(records_result.scalars().all())
record_ids = [record.id for record in records]
now = datetime.now(timezone.utc)
project.deleted_at = now
for record in records:
record.deleted_at = now
freed_size_bytes = await soft_delete_generation_record_resources(
db,
record_ids,
deleted_at=now,
)
await db.flush()
return {"message": "ok"}
return {
"message": "ok",
"project_id": project_id,
"deleted": True,
"deleted_records": len(record_ids),
"freed_size_bytes": freed_size_bytes,
}