爆款/拆镜生成简化3个步骤 | 项目生成可携带附件控制
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -24,7 +24,6 @@ from app.models.credit_ratio import CreditRatio
|
||||
from app.models.operation_log import OperationLog
|
||||
from app.enums.user import FrontendUserKind, UserType
|
||||
from app.enums.team import TEAM_UNASSIGNED_VALUE
|
||||
from app.enums.generation_status import GenerationRecordPipelineStage
|
||||
from app.schemas.admin import (
|
||||
CreditAdjustRequest,
|
||||
ModelConfigCreate,
|
||||
@@ -38,17 +37,12 @@ from app.schemas.admin import (
|
||||
UpdateMenusRequest,
|
||||
ResetPasswordRequest,
|
||||
UpdateFrontendUserKindRequest,
|
||||
OperationLogOut,
|
||||
)
|
||||
from app.schemas.team import UpdateUserTeamRequest
|
||||
from app.schemas.industry import IndustryConfigCreate, IndustryConfigOut
|
||||
from app.schemas.industry import IndustryConfigCreate
|
||||
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
|
||||
@@ -57,23 +51,26 @@ from app.services.auth import hash_password, verify_password
|
||||
from app.services.operation_log import log_operation
|
||||
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls
|
||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||
from app.services.payment import sync_pending_orders, process_refund
|
||||
from app.services.payment import process_refund
|
||||
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
|
||||
from app.services.team_service import batch_get_team_name_map, set_frontend_user_team
|
||||
|
||||
from app.services.generation.billing_service import (
|
||||
OWNER_GENERATION_RECORD,
|
||||
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.utils.id_gen import generate_id
|
||||
from app.schemas.generation import GenerationType, ASPECT_RATIOS, RESOLUTIONS
|
||||
|
||||
|
||||
CST = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def _safe_json_object(value: str | None) -> dict | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
|
||||
def _iso(dt):
|
||||
"""Serialize datetime as naive ISO string (UTC→CST, strip tzinfo)."""
|
||||
if dt is None:
|
||||
@@ -1917,6 +1914,8 @@ async def list_token_usage(
|
||||
async def admin_list_generation_records(
|
||||
user_id: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
engine_id: str | None = Query(None),
|
||||
include_media_references: bool | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=500),
|
||||
admin: User = Depends(get_admin_user),
|
||||
@@ -1935,13 +1934,25 @@ async def admin_list_generation_records(
|
||||
query = query.where(GenerationRecord.user_id == user_id)
|
||||
if status:
|
||||
query = query.where(GenerationRecord.status == status)
|
||||
if engine_id:
|
||||
query = query.where(GenerationRecord.engine_id == engine_id)
|
||||
if include_media_references is not None:
|
||||
query = query.where(GenerationRecord.include_media_references.is_(include_media_references))
|
||||
|
||||
# Count total
|
||||
count_query = select(func.count(GenerationRecord.id)).where(GenerationRecord.deleted_at.is_(None))
|
||||
count_query = (
|
||||
select(func.count(GenerationRecord.id))
|
||||
.join(Project, GenerationRecord.project_id == Project.id)
|
||||
.where(GenerationRecord.deleted_at.is_(None), Project.deleted_at.is_(None))
|
||||
)
|
||||
if user_id:
|
||||
count_query = count_query.where(GenerationRecord.user_id == user_id)
|
||||
if status:
|
||||
count_query = count_query.where(GenerationRecord.status == status)
|
||||
if engine_id:
|
||||
count_query = count_query.where(GenerationRecord.engine_id == engine_id)
|
||||
if include_media_references is not None:
|
||||
count_query = count_query.where(GenerationRecord.include_media_references.is_(include_media_references))
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
@@ -1977,6 +1988,10 @@ async def admin_list_generation_records(
|
||||
"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 '',
|
||||
"references": refs,
|
||||
"engine_id": record.engine_id,
|
||||
"engine_name": (_safe_json_object(record.engine_snapshot_json) or {}).get("name"),
|
||||
"engine_snapshot": _safe_json_object(record.engine_snapshot_json),
|
||||
"include_media_references": bool(record.include_media_references),
|
||||
"credits_cost": record.credits_cost or 0,
|
||||
"text_credits_cost": record.text_credits_cost or 0,
|
||||
"text_tokens_used": record.text_tokens_used or 0,
|
||||
@@ -1997,245 +2012,6 @@ async def admin_list_generation_records(
|
||||
return {"total": total, "items": items}
|
||||
|
||||
|
||||
@router.put("/generation-records/{record_id}/status")
|
||||
async def admin_update_generation_status(
|
||||
record_id: str,
|
||||
body: dict,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""管理员只能终止正在执行或待生成的记录,禁止绕过流水线裸改生成/完成状态。"""
|
||||
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),
|
||||
)
|
||||
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 = str(body.get("status") or "").strip()
|
||||
if new_status in {"generating", "completed", "prompt_optimized"}:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="禁止直接修改为该状态;生成请调用生成接口,完成必须由下载/超分流水线落库",
|
||||
)
|
||||
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
|
||||
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
"管理员终止生成记录",
|
||||
"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_record_resource(
|
||||
record_id: str,
|
||||
body: dict,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""管理员触发 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:
|
||||
raise HTTPException(status_code=409, detail="该任务为画质增强失败,请使用超分恢复命令处理")
|
||||
|
||||
attempt_no = await get_next_credit_attempt_no(
|
||||
db,
|
||||
owner_type=OWNER_GENERATION_RECORD,
|
||||
owner_id=record.id,
|
||||
)
|
||||
|
||||
if record.gen_type == GenerationType.video:
|
||||
aspect_ratio = body.get("aspect_ratio", "16:9")
|
||||
resolution = body.get("resolution", "720p")
|
||||
if aspect_ratio not in ASPECT_RATIOS:
|
||||
raise HTTPException(status_code=400, detail="不支持的画面比例")
|
||||
if 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
|
||||
|
||||
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=resolution,
|
||||
aspect_ratio=aspect_ratio,
|
||||
supported_provider_resolutions=supported_provider_resolutions,
|
||||
)
|
||||
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
|
||||
elif record.gen_type == GenerationType.image:
|
||||
from app.services.image_gen import get_active_image_engine
|
||||
|
||||
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
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="不支持的生成类型")
|
||||
|
||||
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,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"管理员触发生成{type_str}: {record_id}",
|
||||
"POST",
|
||||
f"/admin/generation-records/{record_id}/generate",
|
||||
detail=json.dumps(
|
||||
{
|
||||
"record_id": record_id,
|
||||
"gen_type": record.gen_type,
|
||||
"project_name": project_name,
|
||||
"generation_attempt_no": record.generation_attempt_no,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
return {"message": "ok", "record_id": record_id}
|
||||
|
||||
|
||||
# ── File Uploads ─────────────────────────────────────────
|
||||
|
||||
import os
|
||||
@@ -2251,7 +2027,6 @@ async def upload_pdf(
|
||||
):
|
||||
"""Upload a PDF file and save URL to system config."""
|
||||
from app.config import settings
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="请选择文件")
|
||||
@@ -2313,7 +2088,6 @@ async def upload_logo(
|
||||
):
|
||||
"""Upload a Logo image file and save URL to system config."""
|
||||
from app.config import settings
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="请选择文件")
|
||||
|
||||
Reference in New Issue
Block a user