1
This commit is contained in:
+180
-152
@@ -45,6 +45,10 @@ from app.schemas.industry import IndustryConfigCreate, IndustryConfigOut
|
||||
from app.schemas.video_engine import VideoEngineCreate, VideoEngineOut
|
||||
from app.schemas.image_engine import ImageEngineCreate, ImageEngineOut
|
||||
from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut
|
||||
from app.services.generation.pipeline.db_lock_service import (
|
||||
DatabaseRowLockBusy,
|
||||
execute_with_lock_timeout,
|
||||
)
|
||||
from app.services.credits import add_credits, deduct_credits
|
||||
from app.services.credit_record_meta_service import build_admin_adjust_meta
|
||||
from app.services.admin_credit_record_service import list_admin_credit_records
|
||||
@@ -59,7 +63,7 @@ from app.services.team_service import batch_get_team_name_map, set_frontend_user
|
||||
|
||||
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
|
||||
@@ -545,7 +549,7 @@ async def list_admin_notifications(
|
||||
if user_id:
|
||||
query = query.where(Notification.user_id == user_id)
|
||||
count_query = count_query.where(Notification.user_id == user_id)
|
||||
|
||||
|
||||
if is_read is not None:
|
||||
query = query.where(Notification.is_read == is_read)
|
||||
count_query = count_query.where(Notification.is_read == is_read)
|
||||
@@ -749,11 +753,11 @@ async def get_payment_stats(
|
||||
now_cst = datetime.now(CST)
|
||||
today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_end = today_start + timedelta(days=1)
|
||||
|
||||
|
||||
# Default to today if no date range provided
|
||||
query_start = today_start
|
||||
query_end = today_end
|
||||
|
||||
|
||||
if start_date:
|
||||
query_start = datetime.fromisoformat(start_date).replace(tzinfo=CST)
|
||||
if end_date:
|
||||
@@ -806,7 +810,7 @@ async def get_payment_stats(
|
||||
# Monthly cumulative stats
|
||||
month_start = now_cst.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
month_end = (month_start + timedelta(days=32)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
month_result = await db.execute(
|
||||
select(
|
||||
func.count().label("paid_count"),
|
||||
@@ -1117,11 +1121,15 @@ async def delete_industry_config(
|
||||
|
||||
@router.get("/video-engines", response_model=list[VideoEngineOut])
|
||||
async def list_video_engines(
|
||||
include_deleted: bool = Query(False),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(VideoEngine)
|
||||
if not include_deleted:
|
||||
query = query.where(VideoEngine.deleted_at.is_(None))
|
||||
result = await db.execute(
|
||||
select(VideoEngine).order_by(VideoEngine.priority.desc())
|
||||
query.order_by(VideoEngine.priority.desc(), VideoEngine.id.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -1161,7 +1169,7 @@ async def update_video_engine(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(VideoEngine).where(VideoEngine.id == engine_id).limit(1)
|
||||
select(VideoEngine).where(VideoEngine.id == engine_id, VideoEngine.deleted_at.is_(None)).limit(1)
|
||||
)
|
||||
engine = result.scalar_one_or_none()
|
||||
if not engine:
|
||||
@@ -1194,24 +1202,26 @@ async def delete_video_engine(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(VideoEngine).where(VideoEngine.id == engine_id).limit(1)
|
||||
select(VideoEngine).where(VideoEngine.id == engine_id, VideoEngine.deleted_at.is_(None)).limit(1)
|
||||
)
|
||||
engine = result.scalar_one_or_none()
|
||||
if not engine:
|
||||
raise HTTPException(status_code=404, detail="视频引擎不存在")
|
||||
await db.delete(engine)
|
||||
engine_name = engine.name
|
||||
engine.deleted_at = datetime.now(timezone.utc)
|
||||
engine.is_active = False
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"删除视频引擎: {engine.name}",
|
||||
f"软删除视频引擎: {engine_name}",
|
||||
"DELETE",
|
||||
f"/admin/video-engines/{engine_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"engine_id": engine_id,
|
||||
"name": engine.name,
|
||||
"name": engine_name,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
@@ -1223,11 +1233,15 @@ async def delete_video_engine(
|
||||
|
||||
@router.get("/image-engines", response_model=list[ImageEngineOut])
|
||||
async def list_image_engines(
|
||||
include_deleted: bool = Query(False),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(ImageEngine)
|
||||
if not include_deleted:
|
||||
query = query.where(ImageEngine.deleted_at.is_(None))
|
||||
result = await db.execute(
|
||||
select(ImageEngine).order_by(ImageEngine.priority.desc())
|
||||
query.order_by(ImageEngine.priority.desc(), ImageEngine.id.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -1267,7 +1281,7 @@ async def update_image_engine(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(ImageEngine).where(ImageEngine.id == engine_id).limit(1)
|
||||
select(ImageEngine).where(ImageEngine.id == engine_id, ImageEngine.deleted_at.is_(None)).limit(1)
|
||||
)
|
||||
engine = result.scalar_one_or_none()
|
||||
if not engine:
|
||||
@@ -1300,24 +1314,26 @@ async def delete_image_engine(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(ImageEngine).where(ImageEngine.id == engine_id).limit(1)
|
||||
select(ImageEngine).where(ImageEngine.id == engine_id, ImageEngine.deleted_at.is_(None)).limit(1)
|
||||
)
|
||||
engine = result.scalar_one_or_none()
|
||||
if not engine:
|
||||
raise HTTPException(status_code=404, detail="图片引擎不存在")
|
||||
await db.delete(engine)
|
||||
engine_name = engine.name
|
||||
engine.deleted_at = datetime.now(timezone.utc)
|
||||
engine.is_active = False
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"删除图片引擎: {engine.name}",
|
||||
f"软删除图片引擎: {engine_name}",
|
||||
"DELETE",
|
||||
f"/admin/image-engines/{engine_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"engine_id": engine_id,
|
||||
"name": engine.name,
|
||||
"name": engine_name,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
@@ -1342,7 +1358,7 @@ async def _validate_credit_ratio_engine(db: AsyncSession, req: CreditRatioCreate
|
||||
raise HTTPException(status_code=400, detail="model_config_id 不能为空,当前字段用于保存图片/视频引擎ID")
|
||||
|
||||
model = ImageEngine if gen_type == "image" else VideoEngine
|
||||
result = await db.execute(select(model).where(model.id == engine_id).limit(1))
|
||||
result = await db.execute(select(model).where(model.id == engine_id, model.deleted_at.is_(None)).limit(1))
|
||||
engine = result.scalar_one_or_none()
|
||||
if not engine:
|
||||
detail = "图片积分规则绑定的图片引擎不存在" if gen_type == "image" else "视频积分规则绑定的视频引擎不存在"
|
||||
@@ -1488,10 +1504,14 @@ async def list_credit_ratios_grouped(
|
||||
|
||||
@router.get("/model-configs", response_model=list[ModelConfigOut])
|
||||
async def list_model_configs(
|
||||
include_deleted: bool = Query(False),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(ModelConfig).order_by(ModelConfig.priority.desc()))
|
||||
query = select(ModelConfig)
|
||||
if not include_deleted:
|
||||
query = query.where(ModelConfig.deleted_at.is_(None))
|
||||
result = await db.execute(query.order_by(ModelConfig.priority.desc(), ModelConfig.id.desc()))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@@ -1529,7 +1549,7 @@ async def update_model_config(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(ModelConfig).where(ModelConfig.id == config_id).limit(1))
|
||||
result = await db.execute(select(ModelConfig).where(ModelConfig.id == config_id, ModelConfig.deleted_at.is_(None)).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="配置不存在")
|
||||
@@ -1560,23 +1580,25 @@ async def delete_model_config(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(ModelConfig).where(ModelConfig.id == config_id).limit(1))
|
||||
result = await db.execute(select(ModelConfig).where(ModelConfig.id == config_id, ModelConfig.deleted_at.is_(None)).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="配置不存在")
|
||||
await db.delete(config)
|
||||
config_name = config.name
|
||||
config.deleted_at = datetime.now(timezone.utc)
|
||||
config.is_active = False
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"删除模型配置: {config.name}",
|
||||
f"软删除模型配置: {config_name}",
|
||||
"DELETE",
|
||||
f"/admin/model-configs/{config_id}",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"config_id": config_id,
|
||||
"name": config.name,
|
||||
"name": config_name,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
@@ -1729,14 +1751,14 @@ async def get_stats(
|
||||
Project.created_at <= date_end,
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
|
||||
total_generations = (await db.execute(
|
||||
select(func.count(ChatGenerationTask.id)).where(
|
||||
ChatGenerationTask.created_at >= date_start,
|
||||
ChatGenerationTask.created_at <= date_end,
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
|
||||
total_records = (await db.execute(
|
||||
select(func.count(GenerationRecord.id)).where(
|
||||
GenerationRecord.deleted_at.is_(None),
|
||||
@@ -1744,7 +1766,7 @@ async def get_stats(
|
||||
GenerationRecord.created_at <= date_end,
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
|
||||
total_revenue = (await db.execute(
|
||||
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
|
||||
PaymentOrder.status == "paid",
|
||||
@@ -1780,7 +1802,7 @@ async def get_stats(
|
||||
)).scalar() or 0
|
||||
|
||||
period_duration = date_end - date_start
|
||||
|
||||
|
||||
last_period_start = date_start - period_duration
|
||||
last_period_end = date_start
|
||||
|
||||
@@ -1982,87 +2004,156 @@ async def admin_update_generation_status(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Admin update generation record status (e.g., confirm/reject)."""
|
||||
result = await db.execute(
|
||||
select(GenerationRecord).where(
|
||||
GenerationRecord.id == record_id,
|
||||
GenerationRecord.deleted_at.is_(None),
|
||||
"""管理员只能终止正在执行或待生成的记录,禁止绕过流水线裸改生成/完成状态。"""
|
||||
try:
|
||||
result = await execute_with_lock_timeout(
|
||||
db,
|
||||
select(GenerationRecord).where(
|
||||
GenerationRecord.id == record_id,
|
||||
GenerationRecord.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update()
|
||||
.limit(1),
|
||||
)
|
||||
.with_for_update()
|
||||
.limit(1)
|
||||
)
|
||||
except DatabaseRowLockBusy as exc:
|
||||
raise HTTPException(status_code=409, detail=exc.detail) from exc
|
||||
record = result.scalar_one_or_none()
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
|
||||
new_status = body.get("status")
|
||||
if new_status not in ("prompt_optimized", "generating", "completed", "failed"):
|
||||
raise HTTPException(status_code=400, detail="无效状态")
|
||||
|
||||
if new_status == "failed":
|
||||
await mark_generation_record_failed_and_refund_once(
|
||||
db,
|
||||
record=record,
|
||||
error_message=body.get("error_message") or record.error_message or "管理员设置为失败",
|
||||
new_status = str(body.get("status") or "").strip()
|
||||
if new_status in {"generating", "completed", "prompt_optimized"}:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="禁止直接修改为该状态;生成请调用生成接口,完成必须由下载/超分流水线落库",
|
||||
)
|
||||
else:
|
||||
record.status = new_status
|
||||
if new_status != "failed":
|
||||
raise HTTPException(status_code=400, detail="该接口仅允许管理员终止任务")
|
||||
if record.status == "completed":
|
||||
raise HTTPException(status_code=409, detail="已完成记录不能直接改为失败")
|
||||
|
||||
error_message = body.get("error_message") or record.error_message or "管理员终止生成任务"
|
||||
await mark_generation_record_failed_and_refund_once(
|
||||
db,
|
||||
record=record,
|
||||
error_message=error_message,
|
||||
generation_attempt_no=int(record.generation_attempt_no or 1),
|
||||
)
|
||||
record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
|
||||
record.provider_create_claim_token = None
|
||||
record.provider_create_lease_until = None
|
||||
record.poll_claim_token = None
|
||||
record.poll_lease_until = None
|
||||
record.next_poll_at = None
|
||||
record.download_claim_token = None
|
||||
record.download_lease_until = None
|
||||
record.download_next_retry_at = None
|
||||
|
||||
# 若任务已进入超分,必须同时撤销超分数据库租约;执行中的超分 Worker
|
||||
# 在回填前校验 lease_token,发现 token 被清除后会中止,不得覆盖管理员终止状态。
|
||||
from app.enums.video_upscale import VideoUpscaleStage, VideoUpscaleTaskStatus
|
||||
from app.models.video_upscale_task import VideoUpscaleTask
|
||||
|
||||
try:
|
||||
upscale_result = await execute_with_lock_timeout(
|
||||
db,
|
||||
select(VideoUpscaleTask)
|
||||
.where(VideoUpscaleTask.generation_record_id == record.id)
|
||||
.with_for_update()
|
||||
.limit(1),
|
||||
)
|
||||
except DatabaseRowLockBusy as exc:
|
||||
raise HTTPException(status_code=409, detail=exc.detail) from exc
|
||||
upscale = upscale_result.scalar_one_or_none()
|
||||
if upscale and upscale.status not in {
|
||||
VideoUpscaleTaskStatus.COMPLETED.value,
|
||||
VideoUpscaleTaskStatus.FAILED.value,
|
||||
}:
|
||||
upscale.status = VideoUpscaleTaskStatus.FAILED.value
|
||||
upscale.stage = VideoUpscaleStage.FAILED.value
|
||||
upscale.last_error = error_message
|
||||
upscale.failed_at = datetime.now(CST)
|
||||
upscale.next_retry_at = None
|
||||
upscale.lease_token = None
|
||||
upscale.lease_until = None
|
||||
|
||||
if body.get("video_url"):
|
||||
record.video_url = body["video_url"]
|
||||
if body.get("video_cover_url"):
|
||||
record.video_cover_url = body["video_cover_url"]
|
||||
if body.get("image_url"):
|
||||
record.image_url = body["image_url"]
|
||||
if new_status == "completed":
|
||||
record.generated_at = datetime.now(CST)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"更新生成记录状态: {new_status}",
|
||||
"管理员终止生成记录",
|
||||
"PUT",
|
||||
f"/admin/generation-records/{record_id}/status",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"record_id": record_id,
|
||||
"new_status": new_status,
|
||||
"generation_attempt_no": int(record.generation_attempt_no or 1),
|
||||
"error_message": error_message,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# Redis 注册表只做调度加速;删除失败不回滚已提交的业务终止状态。
|
||||
try:
|
||||
from app.services.celery_download_recovery_service import remove_download_active
|
||||
from app.services.generation.pipeline.owner_service import redis_owner_item_id
|
||||
from app.services.redis_registry_service import redis_remove_registry_item
|
||||
from app.config import settings
|
||||
|
||||
registry_id = redis_owner_item_id(
|
||||
"generation_record",
|
||||
record_id,
|
||||
int(record.generation_attempt_no or 1),
|
||||
)
|
||||
await remove_download_active(registry_id)
|
||||
await redis_remove_registry_item(
|
||||
hash_key=settings.POLL_ACTIVE_REDIS_HASH_KEY,
|
||||
zset_key=settings.POLL_ACTIVE_REDIS_ZSET_KEY,
|
||||
item_id=registry_id,
|
||||
log_context="admin_generation_record_terminate",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {"message": "ok"}
|
||||
|
||||
|
||||
@router.post("/generation-records/{record_id}/generate")
|
||||
async def admin_generate_video(
|
||||
async def admin_generate_record_resource(
|
||||
record_id: str,
|
||||
body: dict,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Admin trigger video/image generation for a record with specified params."""
|
||||
from app.models.project import Project
|
||||
from app.services.video_queue import task_queue
|
||||
|
||||
result = await db.execute(
|
||||
select(GenerationRecord, Project.name)
|
||||
.join(Project, GenerationRecord.project_id == Project.id)
|
||||
.where(
|
||||
GenerationRecord.id == record_id,
|
||||
GenerationRecord.deleted_at.is_(None),
|
||||
Project.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update()
|
||||
"""管理员触发 GenerationRecord 图片或视频资源生成。"""
|
||||
from app.services.generation.pipeline.generation_record_service import (
|
||||
commit_and_enqueue_generation_record,
|
||||
prepare_generation_record_execution,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await execute_with_lock_timeout(
|
||||
db,
|
||||
select(GenerationRecord, Project.name)
|
||||
.join(Project, GenerationRecord.project_id == Project.id)
|
||||
.where(
|
||||
GenerationRecord.id == record_id,
|
||||
GenerationRecord.deleted_at.is_(None),
|
||||
Project.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update(),
|
||||
)
|
||||
except DatabaseRowLockBusy as exc:
|
||||
raise HTTPException(status_code=409, detail=exc.detail) from exc
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
|
||||
record, project_name = row
|
||||
type_str = "视频" if record.gen_type == GenerationType.video else "图片"
|
||||
|
||||
if record.status not in ("prompt_optimized", "failed"):
|
||||
raise HTTPException(status_code=400, detail=f"当前状态不允许生成{type_str}")
|
||||
if record.pipeline_stage == GenerationRecordPipelineStage.UPSCALE_FAILED.value:
|
||||
@@ -2075,7 +2166,6 @@ async def admin_generate_video(
|
||||
)
|
||||
|
||||
if record.gen_type == GenerationType.video:
|
||||
# Video Generation
|
||||
aspect_ratio = body.get("aspect_ratio", "16:9")
|
||||
resolution = body.get("resolution", "720p")
|
||||
if aspect_ratio not in ASPECT_RATIOS:
|
||||
@@ -2097,97 +2187,34 @@ async def admin_generate_video(
|
||||
aspect_ratio=aspect_ratio,
|
||||
supported_provider_resolutions=supported_provider_resolutions,
|
||||
)
|
||||
|
||||
duration = record.duration or 5
|
||||
media_billing = await charge_generation_media_by_params(
|
||||
db,
|
||||
user_id=record.user_id,
|
||||
record_id=record.id,
|
||||
gen_type="video",
|
||||
duration=duration,
|
||||
resolution=resolution,
|
||||
project_name=project_name,
|
||||
description_prefix="视频生成(管理后台)",
|
||||
owner_type=OWNER_GENERATION_RECORD,
|
||||
attempt_no=attempt_no,
|
||||
)
|
||||
|
||||
record.aspect_ratio = aspect_ratio
|
||||
record.resolution = resolution
|
||||
record.provider_generation_resolution = provider_resolution
|
||||
record.video_upscale_enabled_snapshot = upscale_enabled
|
||||
record.video_upscale_snapshot_json = upscale_snapshot_json
|
||||
record.pipeline_stage = GenerationRecordPipelineStage.CREATING_PROVIDER_TASK.value
|
||||
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 submit_video_task
|
||||
task_id = await submit_video_task(
|
||||
db,
|
||||
engine,
|
||||
record,
|
||||
include_media_references=False,
|
||||
)
|
||||
record.seedance_task_id = task_id
|
||||
record.pipeline_stage = GenerationRecordPipelineStage.WAITING_REMOTE.value
|
||||
await db.flush()
|
||||
await task_queue.enqueue(record_id)
|
||||
except Exception as e:
|
||||
record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
|
||||
await mark_generation_record_failed_and_refund_once(
|
||||
db,
|
||||
record=record,
|
||||
error_message=str(e),
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
elif record.gen_type == GenerationType.image:
|
||||
# Image generation
|
||||
from app.services.image_gen import get_active_image_engine
|
||||
|
||||
post_image_size = body.get("image_size", "")
|
||||
image_size = post_image_size or record.image_size or "2K"
|
||||
media_billing = await charge_generation_media_by_params(
|
||||
db,
|
||||
user_id=record.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
|
||||
engine = await get_active_image_engine(db)
|
||||
record.image_size = body.get("image_size") or record.image_size or "2K"
|
||||
record.provider_generation_resolution = None
|
||||
record.video_upscale_enabled_snapshot = False
|
||||
record.video_upscale_snapshot_json = None
|
||||
record.pipeline_stage = None
|
||||
await db.flush()
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="不支持的生成类型")
|
||||
|
||||
try:
|
||||
await task_queue.enqueue(record_id)
|
||||
except Exception as e:
|
||||
await mark_generation_record_failed_and_refund_once(
|
||||
db,
|
||||
record=record,
|
||||
error_message=str(e),
|
||||
)
|
||||
await db.flush()
|
||||
media_billing = await charge_generation_media_for_record(
|
||||
db,
|
||||
record=record,
|
||||
project_name=project_name,
|
||||
description_prefix=f"{type_str}生成(管理后台)-",
|
||||
attempt_no=attempt_no,
|
||||
engine_id=engine.id,
|
||||
)
|
||||
record.credits_cost = round(float(record.credits_cost or 0) + float(media_billing.total_charged or 0), 2)
|
||||
prepare_generation_record_execution(record, engine=engine, attempt_no=attempt_no)
|
||||
await db.flush()
|
||||
await commit_and_enqueue_generation_record(db, record, reason="generation_record_admin_generate")
|
||||
|
||||
await log_operation(
|
||||
db,
|
||||
@@ -2201,6 +2228,7 @@ async def admin_generate_video(
|
||||
"record_id": record_id,
|
||||
"gen_type": record.gen_type,
|
||||
"project_name": project_name,
|
||||
"generation_attempt_no": record.generation_attempt_no,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
|
||||
@@ -62,14 +62,14 @@ async def get_credit_ratios(
|
||||
|
||||
video_engines_result = await db.execute(
|
||||
select(VideoEngine.id)
|
||||
.where(VideoEngine.is_active == True)
|
||||
.where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None))
|
||||
.order_by(VideoEngine.priority.desc())
|
||||
)
|
||||
video_engine_ids = video_engines_result.scalars().all()
|
||||
|
||||
image_engines_result = await db.execute(
|
||||
select(ImageEngine.id)
|
||||
.where(ImageEngine.is_active == True)
|
||||
.where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None))
|
||||
.order_by(ImageEngine.priority.desc())
|
||||
)
|
||||
image_engine_ids = image_engines_result.scalars().all()
|
||||
|
||||
@@ -28,6 +28,10 @@ from app.schemas.generation import (
|
||||
RESOLUTIONS,
|
||||
IMAGE_SIZES,
|
||||
)
|
||||
from app.services.generation.pipeline.db_lock_service import (
|
||||
DatabaseRowLockBusy,
|
||||
execute_with_lock_timeout,
|
||||
)
|
||||
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
|
||||
@@ -73,7 +77,7 @@ def _record_to_out(record: GenerationRecord, project_name: str, refs_override: l
|
||||
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
|
||||
@@ -87,7 +91,7 @@ def _record_to_out(record: GenerationRecord, project_name: str, refs_override: l
|
||||
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,
|
||||
@@ -279,7 +283,7 @@ async def optimize(
|
||||
try:
|
||||
optimized, token_usage = await optimize_prompt(
|
||||
db, req.prompt,
|
||||
user_id=current_user.id,
|
||||
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,
|
||||
@@ -288,7 +292,7 @@ async def optimize(
|
||||
references=req.references,
|
||||
gen_type=req.gen_type,
|
||||
)
|
||||
# Create record BEFORE LLM call so it's visible if user refreshes
|
||||
# LLM 成功后再创建记录;LLM 失败不写 GenerationRecord。
|
||||
record = GenerationRecord(
|
||||
id=generate_id(),
|
||||
user_id=current_user.id,
|
||||
@@ -356,16 +360,22 @@ async def optimize(
|
||||
# 提示词积分不足时,之前已落库的 optimizing 记录必须改为 failed,避免前端长期显示生成中。
|
||||
# 此阶段没有媒体生成扣费,不调用生成失败退款逻辑。
|
||||
await db.rollback()
|
||||
result = await db.execute(
|
||||
select(GenerationRecord)
|
||||
.where(
|
||||
GenerationRecord.id == failed_record_id,
|
||||
GenerationRecord.user_id == failed_user_id,
|
||||
GenerationRecord.deleted_at.is_(None),
|
||||
try:
|
||||
result = await execute_with_lock_timeout(
|
||||
db,
|
||||
select(GenerationRecord)
|
||||
.where(
|
||||
GenerationRecord.id == failed_record_id,
|
||||
GenerationRecord.user_id == failed_user_id,
|
||||
GenerationRecord.deleted_at.is_(None),
|
||||
)
|
||||
.with_for_update()
|
||||
.limit(1),
|
||||
)
|
||||
.with_for_update()
|
||||
.limit(1)
|
||||
)
|
||||
except DatabaseRowLockBusy:
|
||||
# Preserve the original 402 response; a later admin/manual check can
|
||||
# reconcile the rare record-state update lock conflict.
|
||||
raise e
|
||||
failed_record = result.scalar_one_or_none()
|
||||
if failed_record:
|
||||
failed_record.status = "failed"
|
||||
@@ -395,27 +405,30 @@ async def optimize(
|
||||
|
||||
|
||||
@router.post("/{record_id}/generate")
|
||||
async def generate(
|
||||
async def generate_record_resource(
|
||||
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),
|
||||
try:
|
||||
result = await execute_with_lock_timeout(
|
||||
db,
|
||||
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(),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
except DatabaseRowLockBusy as exc:
|
||||
raise HTTPException(status_code=409, detail=exc.detail) from exc
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise RecordNotFoundError()
|
||||
|
||||
record, project_name = row
|
||||
if record.status not in ("prompt_optimized", "failed"):
|
||||
raise InvalidStatusError("当前状态不允许生成")
|
||||
@@ -423,20 +436,18 @@ async def generate(
|
||||
raise InvalidStatusError("该任务生成失败,请联系客服进行修复")
|
||||
|
||||
await assert_user_resource_capacity_available(db, current_user.id)
|
||||
attempt_no = await get_next_credit_attempt_no(db, owner_type=OWNER_GENERATION_RECORD, owner_id=record.id)
|
||||
|
||||
attempt_no = await get_next_credit_attempt_no(
|
||||
db,
|
||||
owner_type=OWNER_GENERATION_RECORD,
|
||||
owner_id=record.id,
|
||||
from app.services.generation.pipeline.generation_record_service import (
|
||||
commit_and_enqueue_generation_record,
|
||||
prepare_generation_record_execution,
|
||||
)
|
||||
|
||||
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="不支持的分辨率")
|
||||
|
||||
from app.services.video_gen import get_active_engine
|
||||
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
|
||||
|
||||
@@ -451,100 +462,39 @@ async def generate(
|
||||
aspect_ratio=req.aspect_ratio,
|
||||
supported_provider_resolutions=supported_provider_resolutions,
|
||||
)
|
||||
|
||||
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=project_name+"-",
|
||||
owner_type=OWNER_GENERATION_RECORD,
|
||||
attempt_no=attempt_no,
|
||||
billing = await charge_generation_media_by_params(
|
||||
db, user_id=current_user.id, record_id=record.id, gen_type="video",
|
||||
duration=record.duration or 5, resolution=req.resolution, engine_id=engine.id,
|
||||
project_name=project_name, description_prefix=project_name + "-",
|
||||
owner_type=OWNER_GENERATION_RECORD, attempt_no=attempt_no,
|
||||
)
|
||||
|
||||
record.aspect_ratio = req.aspect_ratio
|
||||
record.resolution = req.resolution
|
||||
record.provider_generation_resolution = provider_resolution
|
||||
record.video_upscale_enabled_snapshot = upscale_enabled
|
||||
record.video_upscale_snapshot_json = upscale_snapshot_json
|
||||
record.pipeline_stage = GenerationRecordPipelineStage.CREATING_PROVIDER_TASK.value
|
||||
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 submit_video_task
|
||||
from app.services.error_codes import extract_error_message
|
||||
from app.services.video_queue import task_queue
|
||||
|
||||
task_id = await submit_video_task(
|
||||
db,
|
||||
engine,
|
||||
record,
|
||||
include_media_references=False,
|
||||
)
|
||||
record.seedance_task_id = task_id
|
||||
record.pipeline_stage = GenerationRecordPipelineStage.WAITING_REMOTE.value
|
||||
await db.flush()
|
||||
await task_queue.enqueue(record_id)
|
||||
except Exception as e:
|
||||
record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
|
||||
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=project_name+"-",
|
||||
owner_type=OWNER_GENERATION_RECORD,
|
||||
attempt_no=attempt_no,
|
||||
else:
|
||||
from app.services.image_gen import get_active_image_engine
|
||||
engine = await get_active_image_engine(db)
|
||||
image_size = req.image_size or record.image_size or engine.default_size or "2K"
|
||||
billing = await charge_generation_media_by_params(
|
||||
db, user_id=current_user.id, record_id=record.id, gen_type="image",
|
||||
image_size=image_size, engine_id=engine.id, project_name=project_name,
|
||||
description_prefix=project_name + "-", 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
|
||||
record.provider_generation_resolution = None
|
||||
record.video_upscale_enabled_snapshot = False
|
||||
record.video_upscale_snapshot_json = None
|
||||
record.pipeline_stage = 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()
|
||||
record.credits_cost = round(float(record.credits_cost or 0) + float(billing.total_charged or 0), 2)
|
||||
prepare_generation_record_execution(record, engine=engine, attempt_no=attempt_no)
|
||||
await db.flush()
|
||||
await commit_and_enqueue_generation_record(db, record, reason="generation_record_api_generate")
|
||||
|
||||
refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
|
||||
refs = await resolve_private_portrait_reference_display_urls(
|
||||
db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id
|
||||
)
|
||||
return _record_to_out(record, project_name, refs_override=refs)
|
||||
|
||||
|
||||
@@ -554,21 +504,24 @@ async def retry_generation(
|
||||
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),
|
||||
try:
|
||||
result = await execute_with_lock_timeout(
|
||||
db,
|
||||
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(),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
except DatabaseRowLockBusy as exc:
|
||||
raise HTTPException(status_code=409, detail=exc.detail) from exc
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise RecordNotFoundError()
|
||||
|
||||
record, project_name = row
|
||||
if record.status != "failed":
|
||||
raise InvalidStatusError("只有失败的记录可以重试")
|
||||
@@ -576,78 +529,44 @@ async def retry_generation(
|
||||
raise InvalidStatusError("该任务生成失败,请联系客服进行修复")
|
||||
|
||||
await assert_user_resource_capacity_available(db, current_user.id)
|
||||
|
||||
attempt_no = await get_next_credit_attempt_no(
|
||||
db,
|
||||
owner_type=OWNER_GENERATION_RECORD,
|
||||
owner_id=record.id,
|
||||
attempt_no = await get_next_credit_attempt_no(db, owner_type=OWNER_GENERATION_RECORD, owner_id=record.id)
|
||||
from app.services.generation.pipeline.generation_record_service import (
|
||||
commit_and_enqueue_generation_record,
|
||||
prepare_generation_record_execution,
|
||||
)
|
||||
engine = None
|
||||
|
||||
if record.gen_type == GenerationType.video:
|
||||
from app.services.video_gen import get_active_engine
|
||||
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
|
||||
|
||||
engine = await get_active_engine(db)
|
||||
try:
|
||||
supported_provider_resolutions = json.loads(engine.supported_resolutions or "[]")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
supported_provider_resolutions = []
|
||||
provider_resolution, upscale_enabled, upscale_snapshot_json = await build_video_upscale_snapshot(
|
||||
db,
|
||||
target_resolution=record.resolution or "",
|
||||
aspect_ratio=record.aspect_ratio or "",
|
||||
db, target_resolution=record.resolution or "480p", aspect_ratio=record.aspect_ratio or "16:9",
|
||||
supported_provider_resolutions=supported_provider_resolutions,
|
||||
)
|
||||
record.provider_generation_resolution = provider_resolution
|
||||
record.video_upscale_enabled_snapshot = upscale_enabled
|
||||
record.video_upscale_snapshot_json = upscale_snapshot_json
|
||||
record.pipeline_stage = GenerationRecordPipelineStage.CREATING_PROVIDER_TASK.value
|
||||
else:
|
||||
from app.services.image_gen import get_active_image_engine
|
||||
engine = await get_active_image_engine(db)
|
||||
|
||||
media_billing = await charge_generation_media_for_record(
|
||||
db,
|
||||
record=record,
|
||||
project_name=project_name,
|
||||
description_prefix="视频重试",
|
||||
attempt_no=attempt_no,
|
||||
billing = await charge_generation_media_for_record(
|
||||
db, record=record, project_name=project_name, description_prefix="资源生成重试-", attempt_no=attempt_no, engine_id=engine.id
|
||||
)
|
||||
|
||||
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)
|
||||
record.credits_cost = round(float(record.credits_cost or 0) + float(billing.total_charged or 0), 2)
|
||||
record.manual_retry_count = int(record.manual_retry_count or 0) + 1
|
||||
record.retry_count = int(record.manual_retry_count or 0)
|
||||
prepare_generation_record_execution(record, engine=engine, attempt_no=attempt_no)
|
||||
await db.flush()
|
||||
await commit_and_enqueue_generation_record(db, record, reason="generation_record_api_retry")
|
||||
|
||||
try:
|
||||
from app.services.video_queue import task_queue
|
||||
if record.gen_type == GenerationType.video:
|
||||
from app.services.video_gen import submit_video_task
|
||||
assert engine is not None
|
||||
task_id = await submit_video_task(
|
||||
db,
|
||||
engine,
|
||||
record,
|
||||
include_media_references=False,
|
||||
)
|
||||
record.seedance_task_id = task_id
|
||||
record.pipeline_stage = GenerationRecordPipelineStage.WAITING_REMOTE.value
|
||||
await db.flush()
|
||||
await task_queue.enqueue(record_id)
|
||||
except Exception as e:
|
||||
from app.services.error_codes import extract_error_message
|
||||
if record.gen_type == GenerationType.video:
|
||||
record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
|
||||
await mark_generation_record_failed_and_refund_once(
|
||||
db,
|
||||
record=record,
|
||||
error_message=extract_error_message(e, "重试"),
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
|
||||
refs = await resolve_private_portrait_reference_display_urls(
|
||||
db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id
|
||||
)
|
||||
return _record_to_out(record, project_name, refs_override=refs)
|
||||
|
||||
|
||||
@@ -720,11 +639,15 @@ async def get_queue_status(
|
||||
estimated_wait_seconds = None
|
||||
|
||||
if record.status == "generating":
|
||||
resource_started_at = record.resource_generation_started_at or record.created_at
|
||||
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,
|
||||
func.coalesce(
|
||||
GenerationRecord.resource_generation_started_at,
|
||||
GenerationRecord.created_at,
|
||||
) < resource_started_at,
|
||||
)
|
||||
)
|
||||
ahead = ahead_result.scalar() or 0
|
||||
@@ -741,96 +664,6 @@ async def get_queue_status(
|
||||
}
|
||||
|
||||
|
||||
@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 record.status == "completed":
|
||||
return {"message": "already completed"}
|
||||
if str(record.pipeline_stage or "").startswith("upscale_"):
|
||||
return {"message": "upscale already started"}
|
||||
|
||||
if task_status == "succeeded":
|
||||
remote_url = str(data.get("content", {}).get("video_url", "") or "").strip()
|
||||
if not remote_url:
|
||||
record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
|
||||
await mark_generation_record_failed_and_refund_once(
|
||||
db, record=record, error_message="供应商回调成功但未返回视频地址"
|
||||
)
|
||||
else:
|
||||
usage = data.get("usage", {}) if isinstance(data.get("usage"), dict) else {}
|
||||
from app.services.video_queue import handle_generation_record_video_succeeded
|
||||
try:
|
||||
entered_upscale = await handle_generation_record_video_succeeded(
|
||||
db,
|
||||
record,
|
||||
remote_url=remote_url,
|
||||
provider_response=data,
|
||||
video_tokens=usage.get("total_tokens", 0),
|
||||
)
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
result = await db.execute(
|
||||
select(GenerationRecord).where(GenerationRecord.id == record.id).with_for_update().limit(1)
|
||||
)
|
||||
failed_record = result.scalar_one_or_none()
|
||||
if failed_record:
|
||||
failed_record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
|
||||
await mark_generation_record_failed_and_refund_once(
|
||||
db, record=failed_record, error_message=f"视频结果下载失败: {exc}"
|
||||
)
|
||||
entered_upscale = False
|
||||
from app.services.video_gen import _log_video_response
|
||||
_log_video_response(record.id, data)
|
||||
if not entered_upscale and record.status == "completed":
|
||||
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", "视频生成失败")
|
||||
record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
|
||||
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",
|
||||
summary="上传 AI 创作普通参考图片",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.models.chat_generation_task import ChatGenerationTask
|
||||
from app.models.user import User
|
||||
@@ -20,6 +21,10 @@ from app.schemas.generation_ai import (
|
||||
GenerationAITaskListOut,
|
||||
GenerationAITaskOut,
|
||||
)
|
||||
from app.services.generation.pipeline.db_lock_service import (
|
||||
DatabaseRowLockBusy,
|
||||
execute_with_lock_timeout,
|
||||
)
|
||||
from app.services.generation.ai.service import (
|
||||
build_task_out_list,
|
||||
list_generation_ai_engine_options,
|
||||
@@ -715,13 +720,17 @@ async def retry_task(
|
||||
if celery_app is None:
|
||||
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
|
||||
|
||||
result = await db.execute(
|
||||
select(ChatGenerationTask).where(
|
||||
ChatGenerationTask.id == task_id,
|
||||
ChatGenerationTask.user_id == current_user.id,
|
||||
ChatGenerationTask.deleted_at.is_(None),
|
||||
).with_for_update().limit(1)
|
||||
)
|
||||
try:
|
||||
result = await execute_with_lock_timeout(
|
||||
db,
|
||||
select(ChatGenerationTask).where(
|
||||
ChatGenerationTask.id == task_id,
|
||||
ChatGenerationTask.user_id == current_user.id,
|
||||
ChatGenerationTask.deleted_at.is_(None),
|
||||
).with_for_update().limit(1),
|
||||
)
|
||||
except DatabaseRowLockBusy as exc:
|
||||
raise HTTPException(status_code=409, detail=exc.detail) from exc
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
@@ -779,7 +788,7 @@ async def retry_task(
|
||||
enqueue_ids: list[str] = []
|
||||
download_retry_ids: list[str] = []
|
||||
for target in retry_targets:
|
||||
if int(target.retry_count or 0) >= 3:
|
||||
if int(target.manual_retry_count or 0) >= 3:
|
||||
raise HTTPException(status_code=400, detail=f"任务 {target.id} 已超过最大重试次数")
|
||||
|
||||
is_download_retry = bool(
|
||||
@@ -811,6 +820,17 @@ async def retry_task(
|
||||
quantity=quantity,
|
||||
)
|
||||
target.credits_cost = round(float(target.credits_cost or 0) + media_billing.total_charged, 2)
|
||||
resource_started_at = datetime.now(timezone.utc)
|
||||
target.generation_attempt_no = int(attempt_no)
|
||||
target.resource_generation_started_at = resource_started_at
|
||||
if target.gen_type == "image":
|
||||
target.deadline_at = resource_started_at + timedelta(
|
||||
minutes=int(settings.CHATAPI_ASYNC_IMAGE_DEADLINE_MINUTES or 30)
|
||||
)
|
||||
else:
|
||||
target.deadline_at = resource_started_at + timedelta(
|
||||
hours=int(settings.CHATAPI_ASYNC_VIDEO_FINAL_DEADLINE_HOURS or 24)
|
||||
)
|
||||
target.provider_task_id = None
|
||||
target.seedance_task_id = None
|
||||
target.remote_result_url = None
|
||||
@@ -818,6 +838,21 @@ async def retry_task(
|
||||
target.provider_create_claim_token = None
|
||||
target.provider_create_lease_until = None
|
||||
target.provider_create_started_at = None
|
||||
target.poll_started_at = None
|
||||
target.poll_claim_token = None
|
||||
target.poll_lease_until = None
|
||||
target.poll_error_count = 0
|
||||
target.next_poll_at = None
|
||||
target.poll_interval_seconds = 0
|
||||
target.download_celery_task_id = None
|
||||
target.download_enqueued_at = None
|
||||
target.download_started_at = None
|
||||
target.download_claim_token = None
|
||||
target.download_lease_until = None
|
||||
target.download_next_retry_at = None
|
||||
target.download_attempt_count = 0
|
||||
target.download_last_error = None
|
||||
target.download_storage_date_dir = None
|
||||
target.image_url = None
|
||||
target.video_url = None
|
||||
target.video_cover_url = None
|
||||
@@ -832,7 +867,8 @@ async def retry_task(
|
||||
target.poll_count = 0
|
||||
target.last_poll_at = None
|
||||
target.generated_at = None
|
||||
target.retry_count = int(target.retry_count or 0) + 1
|
||||
target.manual_retry_count = int(target.manual_retry_count or 0) + 1
|
||||
target.retry_count = int(target.manual_retry_count or 0)
|
||||
|
||||
if retrying_group_children:
|
||||
await db.flush()
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.enums.common import ModuleProjectStatusEnum
|
||||
from app.enums.generation_task import GenerationOwnerType
|
||||
from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningStepCodeEnum, ModuleCodeEnum
|
||||
from app.schemas.hot_opening_replicate import (
|
||||
HotOpeningActionOut,
|
||||
@@ -610,14 +611,21 @@ async def generate_image(
|
||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
||||
|
||||
try:
|
||||
chatapi_create_generation_task.delay(chat_task_id_value)
|
||||
chatapi_create_generation_task.apply_async(
|
||||
args=[chat_task_id_value],
|
||||
kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": 1},
|
||||
queue="gen_chatapi_create",
|
||||
countdown=0,
|
||||
)
|
||||
except Exception as exc:
|
||||
await _mark_dispatch_failed_and_raise(
|
||||
db,
|
||||
_log_api_error(
|
||||
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
current_user=current_user,
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
message=f"图片生成任务投递失败: {exc}",
|
||||
message=f"图片生成任务投递失败,等待生成恢复任务补投: {exc}",
|
||||
detail={"recoverable": True, "chat_task_id": chat_task_id_value},
|
||||
exc=exc,
|
||||
)
|
||||
|
||||
return HotOpeningActionOut(
|
||||
@@ -747,14 +755,21 @@ async def generate_video(
|
||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
||||
|
||||
try:
|
||||
chatapi_create_generation_task.delay(chat_task_id_value)
|
||||
chatapi_create_generation_task.apply_async(
|
||||
args=[chat_task_id_value],
|
||||
kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": 1},
|
||||
queue="gen_chatapi_create",
|
||||
countdown=0,
|
||||
)
|
||||
except Exception as exc:
|
||||
await _mark_dispatch_failed_and_raise(
|
||||
db,
|
||||
_log_api_error(
|
||||
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
current_user=current_user,
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
message=f"视频生成任务投递失败: {exc}",
|
||||
message=f"视频生成任务投递失败,等待生成恢复任务补投: {exc}",
|
||||
detail={"recoverable": True, "chat_task_id": chat_task_id_value},
|
||||
exc=exc,
|
||||
)
|
||||
|
||||
return HotOpeningActionOut(
|
||||
|
||||
@@ -21,7 +21,7 @@ async def list_active_engines(
|
||||
"""Public endpoint returning active image engine capabilities."""
|
||||
result = await db.execute(
|
||||
select(ImageEngine)
|
||||
.where(ImageEngine.is_active == True)
|
||||
.where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None))
|
||||
.order_by(ImageEngine.priority.desc())
|
||||
)
|
||||
engines = result.scalars().all()
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.enums.generation_task import GenerationOwnerType
|
||||
from app.enums.shot_replicate import (
|
||||
ModuleCodeEnum,
|
||||
ShotAnalysisStatusEnum,
|
||||
@@ -1061,9 +1062,22 @@ async def generate_image(
|
||||
try:
|
||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
||||
|
||||
chatapi_create_generation_task.apply_async(args=[chat_task_id_value], queue="gen_chatapi_create", countdown=0)
|
||||
chatapi_create_generation_task.apply_async(
|
||||
args=[chat_task_id_value],
|
||||
kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": 1},
|
||||
queue="gen_chatapi_create",
|
||||
countdown=0,
|
||||
)
|
||||
except Exception as exc:
|
||||
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"图片生成任务投递失败: {exc}")
|
||||
_log_api_error(
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
current_user=current_user,
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
message=f"图片生成任务投递失败,等待生成恢复任务补投: {exc}",
|
||||
detail={"recoverable": True, "chat_task_id": chat_task_id_value},
|
||||
exc=exc,
|
||||
)
|
||||
|
||||
return ShotReplicateActionOut(message="图片生成任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
|
||||
|
||||
@@ -1148,9 +1162,22 @@ async def generate_video(
|
||||
try:
|
||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
||||
|
||||
chatapi_create_generation_task.apply_async(args=[chat_task_id_value], queue="gen_chatapi_create", countdown=0)
|
||||
chatapi_create_generation_task.apply_async(
|
||||
args=[chat_task_id_value],
|
||||
kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": 1},
|
||||
queue="gen_chatapi_create",
|
||||
countdown=0,
|
||||
)
|
||||
except Exception as exc:
|
||||
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"视频生成任务投递失败: {exc}")
|
||||
_log_api_error(
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
current_user=current_user,
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
message=f"视频生成任务投递失败,等待生成恢复任务补投: {exc}",
|
||||
detail={"recoverable": True, "chat_task_id": chat_task_id_value},
|
||||
exc=exc,
|
||||
)
|
||||
|
||||
return ShotReplicateActionOut(message="视频生成任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ async def list_active_engines(
|
||||
"""Public endpoint returning active video engine capabilities."""
|
||||
result = await db.execute(
|
||||
select(VideoEngine)
|
||||
.where(VideoEngine.is_active == True)
|
||||
.where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None))
|
||||
.order_by(VideoEngine.priority.desc())
|
||||
)
|
||||
engines = result.scalars().all()
|
||||
|
||||
Reference in New Issue
Block a user