生成项目任务/chat任务软删|生成资源管控回收|生成资源token验签API预处理
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user