777 lines
27 KiB
Python
777 lines
27 KiB
Python
import json
|
|
import logging
|
|
import os
|
|
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, File, status
|
|
from fastapi.responses import RedirectResponse
|
|
from sqlalchemy import select, func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import settings
|
|
from app.dependencies import get_db, get_current_user
|
|
from app.models.user import User
|
|
from app.models.project import Project
|
|
from app.models.generation_record import GenerationRecord
|
|
from app.schemas.generation import (
|
|
OptimizeParams,
|
|
GenerateParams,
|
|
GenerationRecordOut,
|
|
GenerationRecordPageListOut,
|
|
OptimizeResult,
|
|
UpdatePromptRequest,
|
|
GenerationType,
|
|
DURATIONS,
|
|
ASPECT_RATIOS,
|
|
RESOLUTIONS,
|
|
IMAGE_SIZES,
|
|
)
|
|
from app.services.credits import deduct_credits, calc_text_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.services.generation_billing_service import (
|
|
OWNER_GENERATION_RECORD,
|
|
charge_generation_media_by_params,
|
|
charge_generation_media_for_record,
|
|
get_next_credit_attempt_no,
|
|
)
|
|
from app.services.generation_refund_service import mark_generation_record_failed_and_refund_once
|
|
from app.services.video_cover_service import async_create_video_cover_for_local_video
|
|
from app.utils.id_gen import generate_id
|
|
from app.utils.exceptions import InsufficientCreditsError, RecordNotFoundError, InvalidStatusError
|
|
|
|
router = APIRouter(prefix="/generation-records", tags=["generation"])
|
|
logger = logging.getLogger("videogen")
|
|
|
|
|
|
def _record_to_out(record: GenerationRecord, project_name: str) -> GenerationRecordOut:
|
|
refs = None
|
|
if record.media_references:
|
|
try:
|
|
refs = json.loads(record.media_references)
|
|
except (json.JSONDecodeError, TypeError):
|
|
refs = None
|
|
|
|
error_message = record.error_message
|
|
if error_message:
|
|
from app.services.error_codes import ARK_ERRORS
|
|
import re
|
|
match = re.search(r"code='([^']+)'", error_message)
|
|
if match:
|
|
code = match.group(1)
|
|
if code in ARK_ERRORS:
|
|
error_message = ARK_ERRORS[code]
|
|
else:
|
|
parts = error_message.split(":")
|
|
if len(parts) >= 2 and parts[1].strip() in ARK_ERRORS:
|
|
error_message = ARK_ERRORS[parts[1].strip()]
|
|
|
|
return GenerationRecordOut(
|
|
id=record.id,
|
|
project_id=record.project_id,
|
|
project_name=project_name,
|
|
original_prompt=record.original_prompt,
|
|
optimized_prompt=record.optimized_prompt,
|
|
gen_type=record.gen_type,
|
|
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,
|
|
status=record.status,
|
|
video_url=build_resource_signed_url(record.video_url) if record.video_url else '',
|
|
video_cover_url=build_resource_signed_url(record.video_cover_url) if record.video_cover_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,
|
|
credits_cost=round(record.credits_cost or 0.00, 2),
|
|
# video_tokens_used=record.video_tokens_used or 0,
|
|
# image_tokens_used=record.image_tokens_used or 0,
|
|
error_message=error_message,
|
|
created_at=record.created_at,
|
|
generated_at=record.generated_at,
|
|
)
|
|
|
|
|
|
@router.get("", response_model=GenerationRecordPageListOut)
|
|
async def list_records(
|
|
project_id: str | None = Query(
|
|
None,
|
|
alias="project_id",
|
|
description="查询单个项目的生成记录",
|
|
),
|
|
status: str | None = Query(
|
|
None,
|
|
description="查询状态,可以不传。prompt_optimized:待生成 | generating:生成中 | failed:失败 | completed:成功",
|
|
examples=["completed"],
|
|
),
|
|
page: int = Query(
|
|
1,
|
|
ge=1,
|
|
description="分页页码,从1开始",
|
|
examples=[1],
|
|
),
|
|
page_size: int = Query(
|
|
10,
|
|
ge=1,
|
|
le=100,
|
|
description="每页返回的生成记录数量,范围 1~100",
|
|
examples=[10],
|
|
),
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
allowed_statuses = {
|
|
"prompt_optimized",
|
|
"generating",
|
|
"failed",
|
|
"completed",
|
|
}
|
|
|
|
if status and status not in allowed_statuses:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="状态参数错误,仅支持:prompt_optimized、generating、failed、completed",
|
|
)
|
|
|
|
offset = (page - 1) * page_size
|
|
|
|
conditions = [
|
|
GenerationRecord.user_id == current_user.id,
|
|
GenerationRecord.deleted_at.is_(None),
|
|
Project.deleted_at.is_(None),
|
|
]
|
|
|
|
if project_id:
|
|
conditions.append(GenerationRecord.project_id == project_id)
|
|
|
|
if status:
|
|
conditions.append(GenerationRecord.status == status)
|
|
|
|
total_result = await db.execute(
|
|
select(func.count(GenerationRecord.id))
|
|
.join(Project, GenerationRecord.project_id == Project.id)
|
|
.where(*conditions)
|
|
)
|
|
total = total_result.scalar_one() or 0
|
|
|
|
query = (
|
|
select(GenerationRecord, Project.name)
|
|
.join(Project, GenerationRecord.project_id == Project.id)
|
|
.where(*conditions)
|
|
.order_by(GenerationRecord.created_at.desc())
|
|
.offset(offset)
|
|
.limit(page_size)
|
|
)
|
|
|
|
result = await db.execute(query)
|
|
rows = result.all()
|
|
|
|
return {
|
|
"total": int(total),
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"items": [
|
|
_record_to_out(record, project_name)
|
|
for record, project_name in rows
|
|
],
|
|
}
|
|
|
|
@router.post("/optimize", response_model=OptimizeResult)
|
|
async def optimize(
|
|
req: OptimizeParams,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
# Validate parameters based on generation type
|
|
if req.gen_type == GenerationType.video:
|
|
if req.duration not in DURATIONS:
|
|
raise HTTPException(status_code=400, detail=f"视频时长必须为{DURATIONS}秒之一")
|
|
if not req.duration:
|
|
raise HTTPException(status_code=400, detail="视频生成需要指定时长")
|
|
elif req.gen_type == GenerationType.image:
|
|
if req.image_size not in IMAGE_SIZES:
|
|
raise HTTPException(status_code=400, detail=f"图片分辨率必须为{IMAGE_SIZES}之一")
|
|
if not req.image_size:
|
|
raise HTTPException(status_code=400, detail="图片生成需要指定画面分辨率")
|
|
|
|
# Idempotency check: if key provided, return existing record if found
|
|
if req.idempotency_key:
|
|
existing = await db.execute(
|
|
select(GenerationRecord, Project.name)
|
|
.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",
|
|
)
|
|
.order_by(GenerationRecord.created_at.desc())
|
|
.limit(1)
|
|
)
|
|
row = existing.first()
|
|
if row:
|
|
record, project_name = row
|
|
return OptimizeResult(
|
|
optimized_prompt=record.optimized_prompt or "",
|
|
text_credits_cost=record.text_credits_cost or 0.00,
|
|
text_tokens_used=record.text_tokens_used or 0,
|
|
record=_record_to_out(record, project_name),
|
|
)
|
|
|
|
# Check project exists and belongs to user
|
|
proj_result = await db.execute(
|
|
select(Project).where(
|
|
Project.id == req.project_id,
|
|
Project.user_id == current_user.id,
|
|
Project.deleted_at.is_(None),
|
|
)
|
|
.limit(1)
|
|
)
|
|
project = proj_result.scalar_one_or_none()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="项目不存在")
|
|
|
|
# Optimize prompt via LLM with type-specific context
|
|
try:
|
|
optimized, token_usage = await optimize_prompt(
|
|
db, req.prompt,
|
|
user_id=current_user.id,
|
|
industry_key=project.industry,
|
|
duration=req.duration if req.gen_type == GenerationType.video else None,
|
|
image_size=req.image_size if req.gen_type == GenerationType.image else None,
|
|
image_proportion=req.image_proportion if req.gen_type == GenerationType.image else None,
|
|
image_px=req.image_px if req.gen_type == GenerationType.image else None,
|
|
references=req.references,
|
|
gen_type=req.gen_type,
|
|
)
|
|
# Create record BEFORE LLM call so it's visible if user refreshes
|
|
record = GenerationRecord(
|
|
id=generate_id(),
|
|
user_id=current_user.id,
|
|
project_id=req.project_id,
|
|
original_prompt=req.prompt,
|
|
gen_type=req.gen_type,
|
|
duration=req.duration,
|
|
image_size=req.image_size,
|
|
image_proportion=req.image_proportion,
|
|
image_px=req.image_px,
|
|
status="optimizing",
|
|
credits_cost=0,
|
|
text_credits_cost=0,
|
|
text_tokens_used=0,
|
|
media_references=json.dumps(req.references) if req.references else None,
|
|
idempotency_key=req.idempotency_key,
|
|
)
|
|
db.add(record)
|
|
await db.flush()
|
|
await db.commit()
|
|
except Exception as e:
|
|
from app.services.error_codes import extract_error_message
|
|
# record.status = "failed"
|
|
# record.error_message = extract_error_message(e, "提示词")
|
|
# await db.flush()
|
|
# await db.commit()
|
|
error_message = extract_error_message(e, "提示词")
|
|
raise HTTPException(
|
|
status_code=502,
|
|
detail=f"AI模型调用失败: {error_message}"
|
|
)
|
|
|
|
text_credits = await calc_text_credits(
|
|
db, token_usage["input_tokens"], token_usage["output_tokens"],
|
|
)
|
|
|
|
await deduct_credits(
|
|
db, current_user.id, text_credits,
|
|
f"提示词优化 - {project.name}",
|
|
)
|
|
|
|
record.optimized_prompt = optimized
|
|
record.status = "prompt_optimized"
|
|
record.text_credits_cost = round(text_credits, 2)
|
|
record.text_tokens_used = token_usage["total_tokens"]
|
|
await db.flush()
|
|
|
|
return OptimizeResult(
|
|
optimized_prompt=optimized,
|
|
text_credits_cost=round(text_credits, 2),
|
|
# text_tokens_used=token_usage["total_tokens"],
|
|
record=_record_to_out(record, project.name),
|
|
)
|
|
|
|
|
|
@router.post("/{record_id}/generate")
|
|
async def generate(
|
|
record_id: str,
|
|
req: GenerateParams,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(GenerationRecord, Project.name)
|
|
.join(Project, GenerationRecord.project_id == Project.id)
|
|
.where(
|
|
GenerationRecord.id == record_id,
|
|
GenerationRecord.user_id == current_user.id,
|
|
GenerationRecord.deleted_at.is_(None),
|
|
Project.deleted_at.is_(None),
|
|
)
|
|
.with_for_update()
|
|
)
|
|
row = result.first()
|
|
if not row:
|
|
raise RecordNotFoundError()
|
|
|
|
record, project_name = row
|
|
if record.status not in ("prompt_optimized", "failed"):
|
|
raise InvalidStatusError("当前状态不允许生成")
|
|
|
|
attempt_no = await get_next_credit_attempt_no(
|
|
db,
|
|
owner_type=OWNER_GENERATION_RECORD,
|
|
owner_id=record.id,
|
|
)
|
|
|
|
if record.gen_type == GenerationType.video:
|
|
# Video generation
|
|
if req.aspect_ratio not in ASPECT_RATIOS:
|
|
raise HTTPException(status_code=400, detail="不支持的画面比例")
|
|
if req.resolution not in RESOLUTIONS:
|
|
raise HTTPException(status_code=400, detail="不支持的分辨率")
|
|
|
|
duration = record.duration or 5
|
|
media_billing = await charge_generation_media_by_params(
|
|
db,
|
|
user_id=current_user.id,
|
|
record_id=record.id,
|
|
gen_type="video",
|
|
duration=duration,
|
|
resolution=req.resolution,
|
|
project_name=project_name,
|
|
description_prefix="视频生成",
|
|
owner_type=OWNER_GENERATION_RECORD,
|
|
attempt_no=attempt_no,
|
|
)
|
|
|
|
record.aspect_ratio = req.aspect_ratio
|
|
record.resolution = req.resolution
|
|
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
|
|
record.status = "generating"
|
|
record.error_message = None
|
|
record.video_url = None
|
|
record.video_cover_url = None
|
|
record.image_url = None
|
|
record.seedance_task_id = None
|
|
await db.flush()
|
|
|
|
try:
|
|
from app.services.video_gen import get_active_engine, submit_video_task
|
|
from app.services.error_codes import extract_error_message
|
|
from app.services.video_queue import task_queue
|
|
|
|
engine = await get_active_engine(db)
|
|
task_id = await submit_video_task(db, engine, record)
|
|
record.seedance_task_id = task_id
|
|
await db.flush()
|
|
await task_queue.enqueue(record_id)
|
|
except Exception as e:
|
|
await mark_generation_record_failed_and_refund_once(
|
|
db,
|
|
record=record,
|
|
error_message=extract_error_message(e, "视频"),
|
|
)
|
|
await db.flush()
|
|
|
|
elif record.gen_type == GenerationType.image:
|
|
image_size = req.image_size or record.image_size or "2K"
|
|
media_billing = await charge_generation_media_by_params(
|
|
db,
|
|
user_id=current_user.id,
|
|
record_id=record.id,
|
|
gen_type="image",
|
|
image_size=image_size,
|
|
project_name=project_name,
|
|
description_prefix="图片生成",
|
|
owner_type=OWNER_GENERATION_RECORD,
|
|
attempt_no=attempt_no,
|
|
)
|
|
|
|
record.image_size = image_size
|
|
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
|
|
record.status = "generating"
|
|
record.error_message = None
|
|
record.image_url = None
|
|
record.video_url = None
|
|
record.video_cover_url = None
|
|
record.seedance_task_id = None
|
|
await db.flush()
|
|
|
|
try:
|
|
from app.services.video_queue import task_queue
|
|
await task_queue.enqueue(record_id)
|
|
except Exception as e:
|
|
await mark_generation_record_failed_and_refund_once(
|
|
db,
|
|
record=record,
|
|
error_message=f"图片任务队列投递失败: {e}",
|
|
)
|
|
await db.flush()
|
|
|
|
return _record_to_out(record, project_name)
|
|
|
|
|
|
@router.post("/{record_id}/retry")
|
|
async def retry_generation(
|
|
record_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(GenerationRecord, Project.name)
|
|
.join(Project, GenerationRecord.project_id == Project.id)
|
|
.where(
|
|
GenerationRecord.id == record_id,
|
|
GenerationRecord.user_id == current_user.id,
|
|
GenerationRecord.deleted_at.is_(None),
|
|
Project.deleted_at.is_(None),
|
|
)
|
|
.with_for_update()
|
|
)
|
|
row = result.first()
|
|
if not row:
|
|
raise RecordNotFoundError()
|
|
|
|
record, project_name = row
|
|
if record.status != "failed":
|
|
raise InvalidStatusError("只有失败的记录可以重试")
|
|
|
|
attempt_no = await get_next_credit_attempt_no(
|
|
db,
|
|
owner_type=OWNER_GENERATION_RECORD,
|
|
owner_id=record.id,
|
|
)
|
|
media_billing = await charge_generation_media_for_record(
|
|
db,
|
|
record=record,
|
|
project_name=project_name,
|
|
description_prefix="视频重试",
|
|
attempt_no=attempt_no,
|
|
)
|
|
|
|
record.status = "generating"
|
|
record.error_message = None
|
|
record.video_url = None
|
|
record.video_cover_url = None
|
|
record.image_url = None
|
|
record.seedance_task_id = None
|
|
record.generated_at = None
|
|
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
|
|
await db.flush()
|
|
|
|
try:
|
|
from app.services.video_queue import task_queue
|
|
if record.gen_type == GenerationType.video:
|
|
from app.services.video_gen import get_active_engine, submit_video_task, extract_error_message
|
|
engine = await get_active_engine(db)
|
|
task_id = await submit_video_task(db, engine, record)
|
|
record.seedance_task_id = task_id
|
|
await db.flush()
|
|
await task_queue.enqueue(record_id)
|
|
except Exception as e:
|
|
from app.services.error_codes import extract_error_message
|
|
await mark_generation_record_failed_and_refund_once(
|
|
db,
|
|
record=record,
|
|
error_message=extract_error_message(e, "重试"),
|
|
)
|
|
await db.flush()
|
|
|
|
return _record_to_out(record, project_name)
|
|
|
|
|
|
@router.put("/{record_id}/prompt")
|
|
async def update_prompt(
|
|
record_id: str,
|
|
req: UpdatePromptRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(GenerationRecord).where(
|
|
GenerationRecord.id == record_id,
|
|
GenerationRecord.user_id == current_user.id,
|
|
GenerationRecord.deleted_at.is_(None),
|
|
)
|
|
.limit(1)
|
|
)
|
|
record = result.scalar_one_or_none()
|
|
if not record:
|
|
raise RecordNotFoundError()
|
|
if record.status != "prompt_optimized":
|
|
raise InvalidStatusError("只有待生成状态可以修改提示词")
|
|
|
|
record.optimized_prompt = req.optimized_prompt
|
|
await db.flush()
|
|
return {"message": "ok"}
|
|
|
|
|
|
@router.get("/{record_id}/video")
|
|
async def get_video(
|
|
record_id: str,
|
|
token: str = Query(...),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Validate temp token and redirect to video URL."""
|
|
validated_id = await validate_and_get_record_id(token)
|
|
if validated_id != record_id:
|
|
raise HTTPException(status_code=403, detail="无效的视频链接")
|
|
|
|
video_url = await get_video_stream_url(db, record_id)
|
|
if not video_url:
|
|
raise HTTPException(status_code=404, detail="视频不存在")
|
|
|
|
return RedirectResponse(url=video_url)
|
|
|
|
|
|
@router.get("/{record_id}/queue-status")
|
|
async def get_queue_status(
|
|
record_id: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get queue position, estimated wait time, and current status for a generation record."""
|
|
from sqlalchemy import func
|
|
|
|
result = await db.execute(
|
|
select(GenerationRecord).where(
|
|
GenerationRecord.id == record_id,
|
|
GenerationRecord.user_id == current_user.id,
|
|
GenerationRecord.deleted_at.is_(None),
|
|
)
|
|
.limit(1)
|
|
)
|
|
record = result.scalar_one_or_none()
|
|
if not record:
|
|
raise RecordNotFoundError()
|
|
|
|
queue_position = None
|
|
estimated_wait_seconds = None
|
|
|
|
if record.status == "generating":
|
|
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,
|
|
)
|
|
)
|
|
ahead = ahead_result.scalar() or 0
|
|
queue_position = ahead + 1
|
|
estimated_wait_seconds = ahead * 60
|
|
|
|
return {
|
|
"record_id": record.id,
|
|
"status": record.status,
|
|
"queue_position": queue_position,
|
|
"estimated_wait_seconds": estimated_wait_seconds,
|
|
}
|
|
|
|
|
|
@router.post("/callbacks/seedance")
|
|
async def seedance_callback(request: Request, db: AsyncSession = Depends(get_db)):
|
|
"""Receive async callback from Seedance API."""
|
|
data = await request.json()
|
|
task_id = data.get("id")
|
|
task_status = data.get("status")
|
|
|
|
if not task_id:
|
|
return {"message": "ignored"}
|
|
|
|
result = await db.execute(
|
|
select(GenerationRecord).where(
|
|
GenerationRecord.seedance_task_id == task_id,
|
|
GenerationRecord.deleted_at.is_(None),
|
|
)
|
|
.with_for_update()
|
|
.limit(1)
|
|
)
|
|
record = result.scalar_one_or_none()
|
|
if not record:
|
|
return {"message": "record not found"}
|
|
|
|
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
|
|
date_dir = datetime.now().strftime("%Y/%m/%d")
|
|
dest_dir = os.path.join(settings.STORAGE_LOCAL_PATH, date_dir)
|
|
os.makedirs(dest_dir, exist_ok=True)
|
|
dest = os.path.join(dest_dir, f"{record.id}.mp4")
|
|
await download_video(remote_url, dest)
|
|
record.video_url = f"/generate/videos/{date_dir}/{record.id}.mp4"
|
|
cover_url, _cover_storage_path = await async_create_video_cover_for_local_video(
|
|
record_id=record.id,
|
|
video_path=dest,
|
|
date_dir=date_dir,
|
|
log_prefix=f"SeedanceCallback视频封面生成 record_id={record.id}",
|
|
)
|
|
record.video_cover_url = cover_url
|
|
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:
|
|
record.video_tokens_used = usage.get("total_tokens", 0)
|
|
# Log callback response
|
|
from app.services.video_gen import _log_video_response
|
|
_log_video_response(record.id, data)
|
|
# Notify user
|
|
from app.services.notification import create_notification
|
|
from app.api.v1.notifications import push_notification_to_user
|
|
notif = await create_notification(
|
|
db, record.user_id, "视频生成完成",
|
|
"您的视频已生成完成,可以查看了。", "video", record.id,
|
|
)
|
|
await push_notification_to_user(record.user_id, notif)
|
|
elif task_status == "failed":
|
|
error_message = data.get("error", "视频生成失败")
|
|
await mark_generation_record_failed_and_refund_once(
|
|
db,
|
|
record=record,
|
|
error_message=error_message,
|
|
)
|
|
# Log callback response
|
|
from app.services.video_gen import _log_video_response
|
|
_log_video_response(record.id, data, error=record.error_message)
|
|
# Notify user
|
|
from app.services.notification import create_notification
|
|
from app.api.v1.notifications import push_notification_to_user
|
|
notif = await create_notification(
|
|
db, record.user_id, "视频生成失败",
|
|
f"视频生成失败:{record.error_message}", "video", record.id,
|
|
)
|
|
await push_notification_to_user(record.user_id, notif)
|
|
|
|
await db.flush()
|
|
return {"message": "ok"}
|
|
|
|
|
|
@router.post("/upload-image")
|
|
async def upload_image(
|
|
file: UploadFile = File(...),
|
|
current_user: User = Depends(get_current_user),
|
|
gen_type: str = Query("video", description="生成类型:video-视频,image-图片"),
|
|
):
|
|
"""Upload an image for generation reference."""
|
|
import os
|
|
import uuid
|
|
from app.config import settings
|
|
from datetime import datetime
|
|
|
|
if not file.content_type or not file.content_type.startswith("image/"):
|
|
raise HTTPException(status_code=400, detail="仅支持图片文件")
|
|
|
|
ext = os.path.splitext(file.filename or ".png")[1] or ".png"
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
safe_name = f"{gen_type}_img_{current_user.id}_{timestamp}_{uuid.uuid4().hex[:8]}{ext}"
|
|
date_dir = datetime.now().strftime("%Y/%m/%d")
|
|
dir_path = os.path.join(settings.UPLOAD_LOCAL_PATH, "images", date_dir)
|
|
os.makedirs(dir_path, exist_ok=True)
|
|
file_path = os.path.join(dir_path, safe_name)
|
|
|
|
content = await file.read()
|
|
if len(content) > 10 * 1024 * 1024:
|
|
raise HTTPException(status_code=400, detail="图片大小不能超过10MB")
|
|
|
|
with open(file_path, "wb") as f:
|
|
f.write(content)
|
|
|
|
url = f"/uploads/images/{date_dir}/{safe_name}"
|
|
return {"url": url, "filename": file.filename or safe_name, "type": "image", "gen_type": gen_type}
|
|
|
|
|
|
@router.post("/upload-video")
|
|
async def upload_video(
|
|
file: UploadFile = File(...),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Upload a video for generation reference."""
|
|
import os
|
|
import uuid
|
|
from app.config import settings
|
|
from datetime import datetime
|
|
|
|
if not file.content_type or not file.content_type.startswith("video/"):
|
|
raise HTTPException(status_code=400, detail="仅支持视频文件")
|
|
|
|
ext = os.path.splitext(file.filename or ".mp4")[1] or ".mp4"
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
safe_name = f"video_ref_{current_user.id}_{timestamp}_{uuid.uuid4().hex[:8]}{ext}"
|
|
date_dir = datetime.now().strftime("%Y/%m/%d")
|
|
dir_path = os.path.join(settings.UPLOAD_LOCAL_PATH, "videos", date_dir)
|
|
os.makedirs(dir_path, exist_ok=True)
|
|
file_path = os.path.join(dir_path, safe_name)
|
|
|
|
content = await file.read()
|
|
if len(content) > 100 * 1024 * 1024:
|
|
raise HTTPException(status_code=400, detail="视频大小不能超过100MB")
|
|
|
|
with open(file_path, "wb") as f:
|
|
f.write(content)
|
|
|
|
url = f"/uploads/videos/{date_dir}/{safe_name}"
|
|
return {"url": url, "filename": file.filename or safe_name, "type": "video"}
|
|
|
|
|
|
@router.post("/delete-file")
|
|
async def delete_upload(
|
|
url: str = Query(..., description="文件URL,如 /uploads/images/2024/01/01/video_img_xxx.png"),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Delete an uploaded file by URL."""
|
|
import os
|
|
from app.config import settings
|
|
|
|
if not url.startswith("/uploads/"):
|
|
raise HTTPException(status_code=400, detail="无效的文件路径")
|
|
|
|
if current_user.id not in url:
|
|
raise HTTPException(status_code=403, detail="无权删除此文件")
|
|
|
|
file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, url.replace("/uploads/", ""))
|
|
if os.path.exists(file_path):
|
|
os.remove(file_path)
|
|
|
|
return {"message": "ok"}
|