This commit is contained in:
2026-07-20 14:01:22 +08:00
parent 53e01f6f74
commit a2e98ef3a3
75 changed files with 5912 additions and 2666 deletions
+180 -152
View File
@@ -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,
),