积分冻结释放
This commit is contained in:
@@ -2,7 +2,7 @@ from datetime import datetime, timezone, timedelta
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy import delete, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, get_admin_user
|
||||
@@ -50,6 +50,8 @@ from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut
|
||||
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
|
||||
from app.services.system_config_cache import invalidate_system_config_cache
|
||||
from app.services.llm_billing.config import validate_llm_system_config_value
|
||||
from app.services.notification import create_notification
|
||||
from app.services.auth import hash_password, verify_password
|
||||
from app.services.operation_log import log_operation
|
||||
@@ -502,6 +504,7 @@ async def list_credit_records(
|
||||
credit_subject: str | None = Query(None),
|
||||
media_type: str | None = Query(None),
|
||||
charge_kind: str | None = Query(None),
|
||||
charge_action: str | None = Query(None),
|
||||
source_module: str | None = Query(None),
|
||||
source_step_code: str | None = Query(None),
|
||||
billing_scene: str | None = Query(None),
|
||||
@@ -524,6 +527,7 @@ async def list_credit_records(
|
||||
credit_subject=credit_subject,
|
||||
media_type=media_type,
|
||||
charge_kind=charge_kind,
|
||||
charge_action=charge_action,
|
||||
source_module=source_module,
|
||||
source_step_code=source_step_code,
|
||||
billing_scene=billing_scene,
|
||||
@@ -1625,6 +1629,10 @@ async def create_system_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from app.utils.id_gen import generate_id
|
||||
try:
|
||||
await validate_llm_system_config_value(db, key=req.key, value=str(req.value))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
config = SystemConfig(
|
||||
id=generate_id(),
|
||||
key=req.key,
|
||||
@@ -1643,6 +1651,8 @@ async def create_system_config(
|
||||
detail=json.dumps({"key": req.key, "value": req.value}, ensure_ascii=False),
|
||||
)
|
||||
await db.commit()
|
||||
await invalidate_system_config_cache([req.key])
|
||||
await db.refresh(config)
|
||||
return config
|
||||
|
||||
|
||||
@@ -1657,6 +1667,10 @@ async def update_system_config(
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
raise HTTPException(status_code=404, detail="配置不存在")
|
||||
try:
|
||||
await validate_llm_system_config_value(db, key=str(config.key), value=str(req.value))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
config.value = str(req.value)
|
||||
await db.flush()
|
||||
await log_operation(
|
||||
@@ -1675,7 +1689,10 @@ async def update_system_config(
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
updated_key = str(config.key)
|
||||
await db.commit()
|
||||
await invalidate_system_config_cache([updated_key])
|
||||
await db.refresh(config)
|
||||
return config
|
||||
|
||||
|
||||
@@ -1785,9 +1802,16 @@ async def get_stats(
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# 预扣占用不是实际消费;历史流水 charge_action 为空时仍按真实扣费兼容。
|
||||
real_credit_charge_filter = or_(
|
||||
CreditRecord.charge_action.is_(None),
|
||||
CreditRecord.charge_action == "charge",
|
||||
)
|
||||
|
||||
credits_consumed = (await db.execute(
|
||||
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
|
||||
CreditRecord.type == "consume",
|
||||
real_credit_charge_filter,
|
||||
CreditRecord.created_at >= date_start,
|
||||
CreditRecord.created_at <= date_end,
|
||||
)
|
||||
@@ -1858,6 +1882,7 @@ async def get_stats(
|
||||
last_period_credits_consumed = (await db.execute(
|
||||
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
|
||||
CreditRecord.type == "consume",
|
||||
real_credit_charge_filter,
|
||||
CreditRecord.created_at >= last_period_start,
|
||||
CreditRecord.created_at <= last_period_end,
|
||||
)
|
||||
@@ -1877,6 +1902,7 @@ async def get_stats(
|
||||
)
|
||||
.where(
|
||||
CreditRecord.type == "consume",
|
||||
real_credit_charge_filter,
|
||||
CreditRecord.created_at >= _chart_start_dt,
|
||||
CreditRecord.created_at <= _chart_end_dt,
|
||||
)
|
||||
@@ -1903,6 +1929,7 @@ async def get_stats(
|
||||
)
|
||||
.where(
|
||||
CreditRecord.type == "consume",
|
||||
real_credit_charge_filter,
|
||||
CreditRecord.created_at >= date_start,
|
||||
CreditRecord.created_at <= date_end,
|
||||
)
|
||||
@@ -1929,6 +1956,7 @@ async def get_stats(
|
||||
)
|
||||
.where(
|
||||
CreditRecord.type == "consume",
|
||||
real_credit_charge_filter,
|
||||
CreditRecord.created_at >= date_start,
|
||||
CreditRecord.created_at <= date_end,
|
||||
)
|
||||
@@ -1949,6 +1977,7 @@ async def get_stats(
|
||||
)
|
||||
.where(
|
||||
CreditRecord.type == "consume",
|
||||
real_credit_charge_filter,
|
||||
CreditRecord.created_at >= date_start,
|
||||
CreditRecord.created_at <= date_end,
|
||||
)
|
||||
@@ -1972,6 +2001,7 @@ async def get_stats(
|
||||
.where(
|
||||
ChatGenerationTask.gen_type == "video",
|
||||
CreditRecord.type == "consume",
|
||||
real_credit_charge_filter,
|
||||
ChatGenerationTask.created_at >= date_start,
|
||||
ChatGenerationTask.created_at <= date_end,
|
||||
ChatGenerationTask.deleted_at.is_(None),
|
||||
|
||||
@@ -15,7 +15,6 @@ 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.models.system_config import SystemConfig
|
||||
from app.schemas.generation import (
|
||||
OptimizeParams,
|
||||
GenerationRecordOut,
|
||||
@@ -27,7 +26,6 @@ from app.services.generation.pipeline.db_lock_service import (
|
||||
DatabaseRowLockBusy,
|
||||
execute_with_lock_timeout,
|
||||
)
|
||||
from app.services.credits import deduct_credits, add_credits, calc_text_credits
|
||||
from app.services.llm import optimize_prompt
|
||||
from app.services.video_url import validate_and_get_record_id, get_video_stream_url
|
||||
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
|
||||
@@ -45,14 +43,27 @@ from app.enums.generation_status import (
|
||||
GenerationType,
|
||||
)
|
||||
from app.enums.common import LogEventStatusEnum
|
||||
from app.enums.credit_record import (
|
||||
CreditRecordBillingScene,
|
||||
CreditRecordChargeKind,
|
||||
CreditRecordSourceModule,
|
||||
)
|
||||
from app.enums.llm_billing import LlmBillingConfigKey
|
||||
from app.services.llm_billing import (
|
||||
LlmBillingContext,
|
||||
log_provider_failure,
|
||||
log_provider_start,
|
||||
log_provider_success,
|
||||
release_on_failure,
|
||||
settle_success,
|
||||
start_hold,
|
||||
)
|
||||
from app.enums.generation_record import (
|
||||
GenerationRecordConfigSourceEnum,
|
||||
GenerationRecordEventTypeEnum,
|
||||
)
|
||||
from app.services.generation.billing_service import (
|
||||
CHARGE_TEXT_PROMPT,
|
||||
OWNER_GENERATION_RECORD,
|
||||
build_credit_biz_key,
|
||||
charge_generation_media_for_record,
|
||||
get_next_credit_attempt_no,
|
||||
)
|
||||
@@ -76,7 +87,6 @@ from app.services.generation.media_reference_service import (
|
||||
calculate_media_reference_usage,
|
||||
validate_media_reference_usage_for_engine,
|
||||
)
|
||||
from app.services.credit_record_meta_service import build_generation_record_prompt_meta
|
||||
from app.enums.audio_reference import (
|
||||
AUDIO_ALLOWED_EXTENSIONS,
|
||||
AUDIO_ALLOWED_MIME_TYPES,
|
||||
@@ -506,29 +516,26 @@ async def optimize(
|
||||
)
|
||||
|
||||
|
||||
hold_credits = 5
|
||||
hold_result = await db.execute(
|
||||
select(SystemConfig).where(SystemConfig.key == "optimize_hold_credits").limit(1)
|
||||
)
|
||||
hold_row = hold_result.scalar_one_or_none()
|
||||
if hold_row and hold_row.value:
|
||||
try:
|
||||
hold_credits = max(0, int(hold_row.value))
|
||||
except (ValueError, TypeError):
|
||||
hold_credits = 5
|
||||
|
||||
hold_scope = req.idempotency_key or generate_id()
|
||||
hold_biz_key = f"optimize_hold:{hold_scope}"
|
||||
hold_refund_biz_key = f"optimize_hold_refund:{hold_scope}"
|
||||
await deduct_credits(
|
||||
db,
|
||||
user_id_snapshot,
|
||||
hold_credits,
|
||||
"AI创作预扣积分",
|
||||
biz_key=hold_biz_key,
|
||||
record_id_value = generate_id()
|
||||
prompt_attempt_no = 1
|
||||
llm_billing_context = LlmBillingContext(
|
||||
user_id=user_id_snapshot,
|
||||
owner_type=OWNER_GENERATION_RECORD,
|
||||
owner_id=record_id_value,
|
||||
attempt_no=prompt_attempt_no,
|
||||
charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value,
|
||||
billing_scene=CreditRecordBillingScene.GENERATION_RECORD_TEXT_PROMPT_OPTIMIZE.value,
|
||||
source_module=CreditRecordSourceModule.GENERATION_RECORD.value,
|
||||
related_id=record_id_value,
|
||||
hold_config_key=LlmBillingConfigKey.HOLD_GENERATION_RECORD_PROMPT.value,
|
||||
description_prefix="AI创作提示词优化",
|
||||
trace_id=f"generation-optimize:{record_id_value}",
|
||||
request_id=req.idempotency_key,
|
||||
)
|
||||
await start_hold(db, llm_billing_context)
|
||||
await db.commit()
|
||||
|
||||
log_provider_start(llm_billing_context, detail={"gen_type": req.gen_type.value})
|
||||
try:
|
||||
optimized, token_usage = await optimize_prompt(
|
||||
db,
|
||||
@@ -544,55 +551,63 @@ async def optimize(
|
||||
log_module="generation_record",
|
||||
log_step="prompt_optimize",
|
||||
log_project_id=req.project_id,
|
||||
log_owner_type=OWNER_GENERATION_RECORD,
|
||||
log_owner_id=record_id_value,
|
||||
generation_attempt_no=prompt_attempt_no,
|
||||
)
|
||||
log_provider_success(llm_billing_context, usage=token_usage)
|
||||
except Exception as exc:
|
||||
from app.services.error_codes import extract_error_message
|
||||
|
||||
await db.rollback()
|
||||
await add_credits(
|
||||
db,
|
||||
user_id_snapshot,
|
||||
hold_credits,
|
||||
"AI创作预扣积分退还",
|
||||
record_type="refund",
|
||||
biz_key=hold_refund_biz_key,
|
||||
refund_for_biz_key=hold_biz_key,
|
||||
)
|
||||
log_provider_failure(llm_billing_context, error=str(exc))
|
||||
await release_on_failure(db, llm_billing_context, error=str(exc))
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"AI模型调用失败: {extract_error_message(exc, '提示词')}",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
text_credits = await calc_text_credits(
|
||||
db,
|
||||
int(token_usage.get("input_tokens", 0) or 0),
|
||||
int(token_usage.get("output_tokens", 0) or 0),
|
||||
)
|
||||
|
||||
record = GenerationRecord(
|
||||
id=generate_id(),
|
||||
user_id=user_id_snapshot,
|
||||
project_id=req.project_id,
|
||||
original_prompt=req.prompt,
|
||||
optimized_prompt=optimized,
|
||||
gen_type=req.gen_type.value,
|
||||
duration=req.duration if req.gen_type == GenerationType.video else None,
|
||||
aspect_ratio=req.aspect_ratio if req.gen_type == GenerationType.video else None,
|
||||
resolution=req.resolution 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,
|
||||
status="prompt_optimized",
|
||||
pipeline_stage=None,
|
||||
credits_cost=0,
|
||||
text_credits_cost=round(text_credits, 2),
|
||||
text_tokens_used=int(token_usage.get("total_tokens", 0) or 0),
|
||||
media_references=json.dumps(req.references, ensure_ascii=False) if req.references else None,
|
||||
include_media_references=bool(req.include_media_references),
|
||||
idempotency_key=req.idempotency_key,
|
||||
async def _persist_optimized_result() -> str:
|
||||
existing_result = await db.execute(
|
||||
select(GenerationRecord)
|
||||
.where(GenerationRecord.id == record_id_value)
|
||||
.with_for_update()
|
||||
.limit(1)
|
||||
)
|
||||
record = existing_result.scalar_one_or_none()
|
||||
if record is None:
|
||||
record = GenerationRecord(
|
||||
id=record_id_value,
|
||||
user_id=user_id_snapshot,
|
||||
project_id=req.project_id,
|
||||
original_prompt=req.prompt,
|
||||
optimized_prompt=optimized,
|
||||
gen_type=req.gen_type.value,
|
||||
duration=req.duration if req.gen_type == GenerationType.video else None,
|
||||
aspect_ratio=req.aspect_ratio if req.gen_type == GenerationType.video else None,
|
||||
resolution=req.resolution 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,
|
||||
status="prompt_optimized",
|
||||
pipeline_stage=None,
|
||||
credits_cost=0,
|
||||
text_credits_cost=0,
|
||||
text_tokens_used=int(token_usage.get("total_tokens", 0) or 0),
|
||||
media_references=json.dumps(req.references, ensure_ascii=False) if req.references else None,
|
||||
include_media_references=bool(req.include_media_references),
|
||||
idempotency_key=req.idempotency_key,
|
||||
)
|
||||
db.add(record)
|
||||
else:
|
||||
# commit 结果不确定或本地持久化重试时,复用同一主键和同一账务 attempt。
|
||||
record.optimized_prompt = optimized
|
||||
record.status = "prompt_optimized"
|
||||
record.pipeline_stage = None
|
||||
record.error_message = None
|
||||
record.text_credits_cost = 0
|
||||
record.text_tokens_used = int(token_usage.get("total_tokens", 0) or 0)
|
||||
|
||||
if req.gen_type == GenerationType.video:
|
||||
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
|
||||
@@ -619,61 +634,41 @@ async def optimize(
|
||||
engine=engine_snapshot_source,
|
||||
source=GenerationRecordConfigSourceEnum.PROMPT_OPTIMIZE,
|
||||
)
|
||||
db.add(record)
|
||||
await db.flush()
|
||||
|
||||
prompt_attempt_no = 1
|
||||
prompt_biz_key = build_credit_biz_key(
|
||||
owner_type=OWNER_GENERATION_RECORD,
|
||||
owner_id=record.id,
|
||||
attempt_no=prompt_attempt_no,
|
||||
charge_kind=CHARGE_TEXT_PROMPT,
|
||||
action="charge",
|
||||
)
|
||||
prompt_meta = await build_generation_record_prompt_meta(
|
||||
billing = await settle_success(
|
||||
db,
|
||||
record_id=record.id,
|
||||
attempt_no=prompt_attempt_no,
|
||||
charge_kind=CHARGE_TEXT_PROMPT,
|
||||
llm_billing_context,
|
||||
usage=token_usage,
|
||||
description=f"提示词优化 - {project_name_snapshot}",
|
||||
)
|
||||
# Release the hold and charge the exact prompt usage in one transaction.
|
||||
await add_credits(
|
||||
db,
|
||||
user_id_snapshot,
|
||||
hold_credits,
|
||||
f"AI创作预扣积分退还 - {project_name_snapshot}",
|
||||
related_id=record.id,
|
||||
record_type="refund",
|
||||
biz_key=hold_refund_biz_key,
|
||||
refund_for_biz_key=hold_biz_key,
|
||||
)
|
||||
await deduct_credits(
|
||||
db,
|
||||
user_id_snapshot,
|
||||
text_credits,
|
||||
f"提示词优化 - {project_name_snapshot}",
|
||||
related_id=record.id,
|
||||
biz_key=prompt_biz_key,
|
||||
record_meta=prompt_meta,
|
||||
charge_item = next(
|
||||
(item for item in billing.items if item.biz_key == llm_billing_context.charge_biz_key),
|
||||
None,
|
||||
)
|
||||
if charge_item:
|
||||
record.text_credits_cost = round(charge_item.amount, 2)
|
||||
record_id_snapshot = str(record.id)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
return record_id_snapshot
|
||||
|
||||
try:
|
||||
record_id_snapshot = await _persist_optimized_result()
|
||||
except Exception as first_exc:
|
||||
await db.rollback()
|
||||
# Any local pricing/snapshot/persistence failure after the provider call
|
||||
# must release the committed hold. The refund key is idempotent.
|
||||
await add_credits(
|
||||
db,
|
||||
user_id_snapshot,
|
||||
hold_credits,
|
||||
"AI创作预扣积分退还",
|
||||
record_type="refund",
|
||||
biz_key=hold_refund_biz_key,
|
||||
refund_for_biz_key=hold_biz_key,
|
||||
logger.exception(
|
||||
"prompt optimize local persistence/settlement failed after provider success; retry once: record_id=%s",
|
||||
record_id_value,
|
||||
)
|
||||
await db.commit()
|
||||
raise
|
||||
try:
|
||||
record_id_snapshot = await _persist_optimized_result()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.exception(
|
||||
"prompt optimize idempotent persistence retry failed; active HOLD retained for repair: record_id=%s",
|
||||
record_id_value,
|
||||
)
|
||||
raise first_exc
|
||||
|
||||
|
||||
refreshed = await db.execute(
|
||||
select(GenerationRecord, Project.name)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
@@ -11,6 +12,12 @@ from app.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.enums.common import ModuleProjectStatusEnum, ModuleEventTypeEnum
|
||||
from app.enums.generation_task import GenerationOwnerType
|
||||
from app.enums.credit_record import (
|
||||
CreditRecordBillingScene,
|
||||
CreditRecordChargeKind,
|
||||
CreditRecordOwnerType,
|
||||
)
|
||||
from app.enums.llm_billing import LlmBillingConfigKey
|
||||
from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningStepCodeEnum, ModuleCodeEnum
|
||||
from app.schemas.hot_opening_replicate import (
|
||||
HotOpeningActionOut,
|
||||
@@ -43,10 +50,20 @@ from app.services.hot_opening_replicate_service import (
|
||||
update_hot_opening_video_prompt_schema,
|
||||
)
|
||||
from app.services.module_generation_log_service import log_module_error, log_module_event_file
|
||||
from app.services.llm_billing import (
|
||||
LlmBillingContext,
|
||||
log_celery_dispatch_compensated,
|
||||
log_celery_dispatch_failure,
|
||||
log_celery_dispatch_start,
|
||||
log_celery_dispatch_success,
|
||||
)
|
||||
from app.services.module_async_recovery_service import (
|
||||
OBJECT_MODULE_STEP,
|
||||
TASK_HOT_IMAGE_PROMPT,
|
||||
TASK_HOT_VIDEO_PROMPT,
|
||||
has_live_object_lock,
|
||||
register_module_step_task,
|
||||
remove_active_task,
|
||||
)
|
||||
from app.tasks.celery_app import celery_app
|
||||
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum
|
||||
@@ -138,6 +155,47 @@ def _log_api_exception_from_locals(exc: BaseException, local_values: dict, messa
|
||||
exc=exc,
|
||||
)
|
||||
|
||||
def _prompt_dispatch_billing_context(
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
step_code: str,
|
||||
attempt_no: int,
|
||||
celery_task_id: str,
|
||||
) -> LlmBillingContext:
|
||||
is_image = step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
|
||||
return LlmBillingContext(
|
||||
user_id=user_id,
|
||||
owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value,
|
||||
owner_id=step_id,
|
||||
attempt_no=attempt_no,
|
||||
charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value,
|
||||
billing_scene=(
|
||||
CreditRecordBillingScene.HOT_OPENING_IMAGE_PROMPT_OPTIMIZE.value
|
||||
if is_image
|
||||
else CreditRecordBillingScene.HOT_OPENING_VIDEO_PROMPT_OPTIMIZE.value
|
||||
),
|
||||
source_module=MODULE,
|
||||
source_project_id=project_id,
|
||||
source_step_id=step_id,
|
||||
source_step_code=step_code,
|
||||
related_id=step_id,
|
||||
hold_config_key=(
|
||||
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
|
||||
if is_image
|
||||
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
|
||||
),
|
||||
description_prefix=(
|
||||
"爆款开头复刻图片AI提词优化"
|
||||
if is_image
|
||||
else "爆款开头复刻视频提词优化"
|
||||
),
|
||||
trace_id=f"hot-opening-prompt:{step_id}:attempt:{attempt_no}",
|
||||
celery_task_id=celery_task_id,
|
||||
)
|
||||
|
||||
|
||||
async def _reload_project_detail(
|
||||
db: AsyncSession,
|
||||
current_user: User,
|
||||
@@ -161,10 +219,26 @@ async def _mark_dispatch_failed_and_raise(
|
||||
project_id: str,
|
||||
step_id: str | None,
|
||||
message: str,
|
||||
billing_context: LlmBillingContext | None = None,
|
||||
) -> None:
|
||||
"""Celery 投递失败后,数据库事务已提交,单独标记步骤失败,避免一直 processing。"""
|
||||
"""Celery 投递失败后补偿步骤和冻结积分,避免一直 processing。"""
|
||||
if billing_context is not None:
|
||||
log_celery_dispatch_failure(billing_context, error=message)
|
||||
compensated = False
|
||||
if step_id:
|
||||
try:
|
||||
if await has_live_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id):
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
user_id=_safe_user_id(current_user),
|
||||
message="Celery 投递返回异常,但 worker 已领取任务,跳过失败补偿",
|
||||
detail={"reason": "uncertain_dispatch_worker_started", "dispatch_error": message},
|
||||
error=message,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=f"{message};任务可能已被 worker 接收,请勿重复提交")
|
||||
await mark_hot_opening_step_dispatch_failed(
|
||||
db,
|
||||
current_user=_user_context(current_user),
|
||||
@@ -173,6 +247,23 @@ async def _mark_dispatch_failed_and_raise(
|
||||
error_message=message,
|
||||
)
|
||||
await db.commit()
|
||||
compensated = True
|
||||
if billing_context is not None:
|
||||
log_celery_dispatch_compensated(billing_context, error=message)
|
||||
try:
|
||||
await remove_active_task(object_type=OBJECT_MODULE_STEP, object_id=step_id)
|
||||
except Exception as cleanup_exc:
|
||||
_log_api_error(
|
||||
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
message="Celery 投递补偿完成,但清理 active registry 失败",
|
||||
detail={"dispatch_error": message},
|
||||
exc=cleanup_exc,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_error(
|
||||
@@ -191,12 +282,89 @@ async def _mark_dispatch_failed_and_raise(
|
||||
step_id=step_id,
|
||||
user_id=_safe_user_id(current_user),
|
||||
message=message,
|
||||
detail={"reason": "celery_dispatch_failed"},
|
||||
detail={"reason": "celery_dispatch_failed", "compensated": compensated},
|
||||
error=message,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=message)
|
||||
|
||||
|
||||
async def _dispatch_prompt_task(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
step_code: str,
|
||||
task_name: str,
|
||||
celery_task: Any,
|
||||
celery_task_id: str,
|
||||
billing_context: LlmBillingContext,
|
||||
error_prefix: str,
|
||||
) -> None:
|
||||
"""Redis 注册与 Celery 直投任一成功即视为可恢复投递。"""
|
||||
registry_error: Exception | None = None
|
||||
try:
|
||||
await register_module_step_task(
|
||||
module=MODULE,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
step_code=step_code,
|
||||
task_name=task_name,
|
||||
)
|
||||
except Exception as exc:
|
||||
registry_error = exc
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
user_id=_safe_user_id(current_user),
|
||||
message="提词任务 Redis 活跃注册失败,将继续尝试 Celery 直投",
|
||||
detail={"channel": "active_registry"},
|
||||
exc=exc,
|
||||
)
|
||||
|
||||
celery_error: Exception | None = None
|
||||
try:
|
||||
celery_task.apply_async(
|
||||
args=[project_id, step_id],
|
||||
queue="gen_chatapi_create",
|
||||
countdown=0,
|
||||
task_id=celery_task_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
celery_error = exc
|
||||
|
||||
if celery_error is None:
|
||||
log_celery_dispatch_success(billing_context)
|
||||
return
|
||||
if registry_error is None:
|
||||
log_celery_dispatch_failure(
|
||||
billing_context,
|
||||
error=f"Celery 直投失败,已保留 active registry 等待恢复:{celery_error}",
|
||||
)
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
user_id=_safe_user_id(current_user),
|
||||
message="Celery 直投失败,任务将由 active registry 恢复投递",
|
||||
detail={"recoverable": True, "celery_task_id": celery_task_id},
|
||||
error=str(celery_error),
|
||||
)
|
||||
return
|
||||
|
||||
await _mark_dispatch_failed_and_raise(
|
||||
db,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
message=f"{error_prefix}: Redis 注册失败({registry_error});Celery 投递失败({celery_error})",
|
||||
billing_context=billing_context,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/spec",
|
||||
response_model=HotOpeningSpecOut,
|
||||
@@ -514,6 +682,17 @@ async def generate_image_prompt(
|
||||
project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, material_step_id=step_id)
|
||||
project_id_value = str(project.id)
|
||||
step_id_value = str(step.id)
|
||||
user_id_value = str(project.user_id)
|
||||
attempt_no_value = int(step.version or 1)
|
||||
celery_task_id = f"hot-opening:image-prompt:{step_id_value}"
|
||||
billing_context = _prompt_dispatch_billing_context(
|
||||
user_id=user_id_value,
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
|
||||
attempt_no=attempt_no_value,
|
||||
celery_task_id=celery_task_id,
|
||||
)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
@@ -525,23 +704,19 @@ async def generate_image_prompt(
|
||||
|
||||
from app.tasks.hot_opening_replicate_tasks import start_image_prompt_optimize
|
||||
|
||||
await register_module_step_task(
|
||||
module=MODULE,
|
||||
log_celery_dispatch_start(billing_context)
|
||||
await _dispatch_prompt_task(
|
||||
db,
|
||||
current_user=current_user,
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
|
||||
task_name=TASK_HOT_IMAGE_PROMPT,
|
||||
celery_task=start_image_prompt_optimize,
|
||||
celery_task_id=celery_task_id,
|
||||
billing_context=billing_context,
|
||||
error_prefix="图片提词任务投递失败",
|
||||
)
|
||||
try:
|
||||
start_image_prompt_optimize.apply_async(args=[project_id_value, step_id_value], 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}",
|
||||
)
|
||||
|
||||
return HotOpeningActionOut(
|
||||
message="图片 AI 提词任务已提交",
|
||||
@@ -657,6 +832,17 @@ async def generate_video_prompt(
|
||||
project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, image_step_id=step_id, req=req)
|
||||
project_id_value = str(project.id)
|
||||
step_id_value = str(step.id)
|
||||
user_id_value = str(project.user_id)
|
||||
attempt_no_value = int(step.version or 1)
|
||||
celery_task_id = f"hot-opening:video-prompt:{step_id_value}"
|
||||
billing_context = _prompt_dispatch_billing_context(
|
||||
user_id=user_id_value,
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
|
||||
attempt_no=attempt_no_value,
|
||||
celery_task_id=celery_task_id,
|
||||
)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
@@ -668,23 +854,19 @@ async def generate_video_prompt(
|
||||
|
||||
from app.tasks.hot_opening_replicate_tasks import start_video_prompt_optimize
|
||||
|
||||
await register_module_step_task(
|
||||
module=MODULE,
|
||||
log_celery_dispatch_start(billing_context)
|
||||
await _dispatch_prompt_task(
|
||||
db,
|
||||
current_user=current_user,
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
|
||||
task_name=TASK_HOT_VIDEO_PROMPT,
|
||||
celery_task=start_video_prompt_optimize,
|
||||
celery_task_id=celery_task_id,
|
||||
billing_context=billing_context,
|
||||
error_prefix="视频提词任务投递失败",
|
||||
)
|
||||
try:
|
||||
start_video_prompt_optimize.apply_async(args=[project_id_value, step_id_value], 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}",
|
||||
)
|
||||
|
||||
return HotOpeningActionOut(
|
||||
message="视频 AI 提词任务已提交",
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
@@ -13,6 +14,12 @@ from app.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.enums.common import ModuleEventTypeEnum
|
||||
from app.enums.generation_task import GenerationOwnerType
|
||||
from app.enums.credit_record import (
|
||||
CreditRecordBillingScene,
|
||||
CreditRecordChargeKind,
|
||||
CreditRecordOwnerType,
|
||||
)
|
||||
from app.enums.llm_billing import LlmBillingConfigKey
|
||||
from app.enums.shot_replicate import (
|
||||
ModuleCodeEnum,
|
||||
ShotAnalysisStatusEnum,
|
||||
@@ -65,6 +72,7 @@ from app.services.shot_replicate_flow_service import (
|
||||
update_shot_replicate_video_prompt_schema,
|
||||
)
|
||||
from app.services.shot_replicate_taskset_service import (
|
||||
build_task_set_analysis_billing_context,
|
||||
create_custom_segment,
|
||||
create_segments_by_ai,
|
||||
create_task_set,
|
||||
@@ -72,6 +80,9 @@ from app.services.shot_replicate_taskset_service import (
|
||||
delete_task_set,
|
||||
list_segments,
|
||||
list_task_sets,
|
||||
mark_custom_segment_split_dispatch_failed,
|
||||
mark_segment_analysis_dispatch_failed,
|
||||
mark_task_set_analysis_dispatch_failed,
|
||||
prepare_reanalyze_segment,
|
||||
prepare_reanalyze_task_set,
|
||||
prepare_retry_split_segment,
|
||||
@@ -79,10 +90,20 @@ from app.services.shot_replicate_taskset_service import (
|
||||
task_set_detail,
|
||||
)
|
||||
from app.services.module_generation_log_service import log_module_error, log_module_event_file
|
||||
from app.services.llm_billing import (
|
||||
LlmBillingContext,
|
||||
log_celery_dispatch_compensated,
|
||||
log_celery_dispatch_failure,
|
||||
log_celery_dispatch_start,
|
||||
log_celery_dispatch_success,
|
||||
)
|
||||
from app.services.module_async_recovery_service import (
|
||||
OBJECT_MODULE_STEP,
|
||||
TASK_SHOT_IMAGE_PROMPT,
|
||||
TASK_SHOT_VIDEO_PROMPT,
|
||||
has_live_object_lock,
|
||||
register_module_step_task,
|
||||
remove_active_task,
|
||||
)
|
||||
from app.tasks.celery_app import celery_app
|
||||
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum
|
||||
@@ -187,6 +208,83 @@ def _ensure_celery_enabled(*, current_user: User | None = None, project_id: str
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=message)
|
||||
|
||||
def _prompt_dispatch_billing_context(
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
step_code: str,
|
||||
attempt_no: int,
|
||||
celery_task_id: str,
|
||||
) -> LlmBillingContext:
|
||||
is_image = step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
|
||||
return LlmBillingContext(
|
||||
user_id=user_id,
|
||||
owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value,
|
||||
owner_id=step_id,
|
||||
attempt_no=attempt_no,
|
||||
charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value,
|
||||
billing_scene=(
|
||||
CreditRecordBillingScene.SHOT_IMAGE_PROMPT_OPTIMIZE.value
|
||||
if is_image
|
||||
else CreditRecordBillingScene.SHOT_VIDEO_PROMPT_OPTIMIZE.value
|
||||
),
|
||||
source_module=MODULE,
|
||||
source_project_id=project_id,
|
||||
source_step_id=step_id,
|
||||
source_step_code=step_code,
|
||||
related_id=step_id,
|
||||
hold_config_key=(
|
||||
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
|
||||
if is_image
|
||||
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
|
||||
),
|
||||
description_prefix=(
|
||||
"拆镜复刻图片AI提词优化" if is_image else "拆镜复刻视频提词优化"
|
||||
),
|
||||
trace_id=f"shot-replicate-prompt:{step_id}:attempt:{attempt_no}",
|
||||
celery_task_id=celery_task_id,
|
||||
)
|
||||
|
||||
|
||||
def _analysis_dispatch_billing_context(
|
||||
*,
|
||||
user_id: str,
|
||||
owner_id: str,
|
||||
attempt_no: int,
|
||||
task_set_id: str,
|
||||
is_segment: bool,
|
||||
celery_task_id: str,
|
||||
) -> LlmBillingContext:
|
||||
return LlmBillingContext(
|
||||
user_id=user_id,
|
||||
owner_type=(
|
||||
CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value
|
||||
if is_segment
|
||||
else CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.value
|
||||
),
|
||||
owner_id=owner_id,
|
||||
attempt_no=attempt_no,
|
||||
charge_kind=CreditRecordChargeKind.VIDEO_ANALYSIS.value,
|
||||
billing_scene=(
|
||||
CreditRecordBillingScene.SHOT_SEGMENT_VIDEO_ANALYSIS.value
|
||||
if is_segment
|
||||
else CreditRecordBillingScene.SHOT_ORIGINAL_VIDEO_ANALYSIS.value
|
||||
),
|
||||
source_module=MODULE,
|
||||
source_project_id=task_set_id,
|
||||
source_step_id=owner_id,
|
||||
source_step_code=ShotReplicateStepCodeEnum.VIDEO_ANALYSIS.value,
|
||||
related_id=owner_id,
|
||||
hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
|
||||
description_prefix=(
|
||||
"拆镜复刻片段视频AI分析" if is_segment else "拆镜复刻原视频AI分析"
|
||||
),
|
||||
trace_id=f"shot-analysis:{owner_id}:attempt:{attempt_no}",
|
||||
celery_task_id=celery_task_id,
|
||||
)
|
||||
|
||||
|
||||
async def _reload_project_detail(db: AsyncSession, current_user: User, project_id: str) -> ShotReplicateTaskDetailOut:
|
||||
project = await _get_project_for_user(
|
||||
db,
|
||||
@@ -205,9 +303,25 @@ async def _mark_dispatch_failed_and_raise(
|
||||
project_id: str,
|
||||
step_id: str | None,
|
||||
message: str,
|
||||
billing_context: LlmBillingContext | None = None,
|
||||
) -> None:
|
||||
if billing_context is not None:
|
||||
log_celery_dispatch_failure(billing_context, error=message)
|
||||
compensated = False
|
||||
if step_id:
|
||||
try:
|
||||
if await has_live_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id):
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
user_id=_safe_user_id(current_user),
|
||||
message="Celery 投递返回异常,但 worker 已领取任务,跳过失败补偿",
|
||||
detail={"reason": "uncertain_dispatch_worker_started", "dispatch_error": message},
|
||||
error=message,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=f"{message};任务可能已被 worker 接收,请勿重复提交")
|
||||
await mark_shot_replicate_step_dispatch_failed(
|
||||
db,
|
||||
current_user=_user_context(current_user),
|
||||
@@ -216,6 +330,23 @@ async def _mark_dispatch_failed_and_raise(
|
||||
error_message=message,
|
||||
)
|
||||
await db.commit()
|
||||
compensated = True
|
||||
if billing_context is not None:
|
||||
log_celery_dispatch_compensated(billing_context, error=message)
|
||||
try:
|
||||
await remove_active_task(object_type=OBJECT_MODULE_STEP, object_id=step_id)
|
||||
except Exception as cleanup_exc:
|
||||
_log_api_error(
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
message="Celery 投递补偿完成,但清理 active registry 失败",
|
||||
detail={"dispatch_error": message},
|
||||
exc=cleanup_exc,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
_log_api_error(
|
||||
@@ -234,12 +365,89 @@ async def _mark_dispatch_failed_and_raise(
|
||||
step_id=step_id,
|
||||
user_id=_safe_user_id(current_user),
|
||||
message=message,
|
||||
detail={"reason": "celery_dispatch_failed"},
|
||||
detail={"reason": "celery_dispatch_failed", "compensated": compensated},
|
||||
error=message,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=message)
|
||||
|
||||
|
||||
async def _dispatch_prompt_task(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
step_code: str,
|
||||
task_name: str,
|
||||
celery_task: Any,
|
||||
celery_task_id: str,
|
||||
billing_context: LlmBillingContext,
|
||||
error_prefix: str,
|
||||
) -> None:
|
||||
"""Redis 注册与 Celery 直投任一成功即视为可恢复投递。"""
|
||||
registry_error: Exception | None = None
|
||||
try:
|
||||
await register_module_step_task(
|
||||
module=MODULE,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
step_code=step_code,
|
||||
task_name=task_name,
|
||||
)
|
||||
except Exception as exc:
|
||||
registry_error = exc
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
user_id=_safe_user_id(current_user),
|
||||
message="提词任务 Redis 活跃注册失败,将继续尝试 Celery 直投",
|
||||
detail={"channel": "active_registry"},
|
||||
exc=exc,
|
||||
)
|
||||
|
||||
celery_error: Exception | None = None
|
||||
try:
|
||||
celery_task.apply_async(
|
||||
args=[project_id, step_id],
|
||||
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
|
||||
countdown=0,
|
||||
task_id=celery_task_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
celery_error = exc
|
||||
|
||||
if celery_error is None:
|
||||
log_celery_dispatch_success(billing_context)
|
||||
return
|
||||
if registry_error is None:
|
||||
log_celery_dispatch_failure(
|
||||
billing_context,
|
||||
error=f"Celery 直投失败,已保留 active registry 等待恢复:{celery_error}",
|
||||
)
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
user_id=_safe_user_id(current_user),
|
||||
message="Celery 直投失败,任务将由 active registry 恢复投递",
|
||||
detail={"recoverable": True, "celery_task_id": celery_task_id},
|
||||
error=str(celery_error),
|
||||
)
|
||||
return
|
||||
|
||||
await _mark_dispatch_failed_and_raise(
|
||||
db,
|
||||
current_user=current_user,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
message=f"{error_prefix}: Redis 注册失败({registry_error});Celery 投递失败({celery_error})",
|
||||
billing_context=billing_context,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/spec",
|
||||
response_model=ShotReplicateSpecOut,
|
||||
@@ -327,8 +535,16 @@ async def create_shot_task_set(
|
||||
):
|
||||
_ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id"))
|
||||
try:
|
||||
task_set = await create_task_set(db, current_user=current_user, req=req)
|
||||
task_set_id = task_set.id
|
||||
task_set, created_new = await create_task_set(db, current_user=current_user, req=req)
|
||||
task_set_id = str(task_set.id)
|
||||
if not created_new:
|
||||
# 幂等重复请求不重复预扣和投递;已有 pending 任务由原投递或恢复任务继续处理。
|
||||
await db.rollback()
|
||||
return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id)
|
||||
analysis_attempt_no = max(1, int(task_set.analysis_attempt_no or 1))
|
||||
celery_task_id = f"shot-analysis:task-set:{task_set_id}:attempt:{analysis_attempt_no}"
|
||||
billing_context = build_task_set_analysis_billing_context(task_set)
|
||||
billing_context.celery_task_id = celery_task_id
|
||||
await bind_upload_resources(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
@@ -348,11 +564,19 @@ async def create_shot_task_set(
|
||||
_log_api_exception_from_locals(exc, locals(), f"创建拆镜总任务集失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"创建拆镜总任务集失败: {exc}")
|
||||
|
||||
log_celery_dispatch_start(billing_context)
|
||||
try:
|
||||
from app.tasks.shot_replicate_tasks import analyze_original_video
|
||||
|
||||
analyze_original_video.apply_async(args=[task_set_id], queue=CeleryQueue.GEN_SHOT_ANALYSIS.value, countdown=0)
|
||||
analyze_original_video.apply_async(
|
||||
args=[task_set_id],
|
||||
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
|
||||
countdown=0,
|
||||
task_id=celery_task_id,
|
||||
)
|
||||
log_celery_dispatch_success(billing_context)
|
||||
except Exception as exc:
|
||||
log_celery_dispatch_failure(billing_context, error=str(exc))
|
||||
_log_api_error(
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
current_user=current_user,
|
||||
@@ -361,6 +585,26 @@ async def create_shot_task_set(
|
||||
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
|
||||
exc=exc,
|
||||
)
|
||||
try:
|
||||
compensated = await mark_task_set_analysis_dispatch_failed(
|
||||
db,
|
||||
current_user=_user_context(current_user),
|
||||
task_set_id=task_set_id,
|
||||
error_message=f"拆镜分析任务投递失败: {exc}",
|
||||
)
|
||||
await db.commit()
|
||||
if compensated:
|
||||
log_celery_dispatch_compensated(billing_context, error=str(exc))
|
||||
except Exception as mark_exc:
|
||||
await db.rollback()
|
||||
_log_api_error(
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
|
||||
current_user=current_user,
|
||||
project_id=task_set_id,
|
||||
message="拆镜分析任务投递失败后补偿失败",
|
||||
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
|
||||
exc=mark_exc,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=f"拆镜分析任务投递失败: {exc}")
|
||||
|
||||
return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id)
|
||||
@@ -445,6 +689,16 @@ async def reanalyze_task_set(
|
||||
force=req.force,
|
||||
reason=req.reason,
|
||||
)
|
||||
analysis_attempt_no = int(out.analysis_attempt_no)
|
||||
celery_task_id = f"shot-analysis:task-set:{task_set_id}:attempt:{analysis_attempt_no}"
|
||||
billing_context = _analysis_dispatch_billing_context(
|
||||
user_id=str(current_user.id),
|
||||
owner_id=task_set_id,
|
||||
attempt_no=analysis_attempt_no,
|
||||
task_set_id=task_set_id,
|
||||
is_segment=False,
|
||||
celery_task_id=celery_task_id,
|
||||
)
|
||||
await db.commit()
|
||||
except HTTPException as exc:
|
||||
await db.rollback()
|
||||
@@ -470,10 +724,17 @@ async def reanalyze_task_set(
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"原视频再次分析状态重置失败: {exc}")
|
||||
|
||||
log_celery_dispatch_start(billing_context)
|
||||
try:
|
||||
from app.tasks.shot_replicate_tasks import analyze_original_video
|
||||
|
||||
analyze_original_video.apply_async(args=[task_set_id], queue=CeleryQueue.GEN_SHOT_ANALYSIS.value, countdown=0)
|
||||
analyze_original_video.apply_async(
|
||||
args=[task_set_id],
|
||||
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
|
||||
countdown=0,
|
||||
task_id=celery_task_id,
|
||||
)
|
||||
log_celery_dispatch_success(billing_context)
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_SUBMITTED.value,
|
||||
@@ -483,6 +744,7 @@ async def reanalyze_task_set(
|
||||
detail={"task_set_id": task_set_id, "task": "analyze_original_video", "request": req.model_dump()},
|
||||
)
|
||||
except Exception as exc:
|
||||
log_celery_dispatch_failure(billing_context, error=str(exc))
|
||||
_log_api_error(
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
current_user=current_user,
|
||||
@@ -491,6 +753,26 @@ async def reanalyze_task_set(
|
||||
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
|
||||
exc=exc,
|
||||
)
|
||||
try:
|
||||
compensated = await mark_task_set_analysis_dispatch_failed(
|
||||
db,
|
||||
current_user=_user_context(current_user),
|
||||
task_set_id=task_set_id,
|
||||
error_message=f"原视频再次分析任务投递失败: {exc}",
|
||||
)
|
||||
await db.commit()
|
||||
if compensated:
|
||||
log_celery_dispatch_compensated(billing_context, error=str(exc))
|
||||
except Exception as mark_exc:
|
||||
await db.rollback()
|
||||
_log_api_error(
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
|
||||
current_user=current_user,
|
||||
project_id=task_set_id,
|
||||
message="原视频再次分析任务投递失败后补偿失败",
|
||||
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
|
||||
exc=mark_exc,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=f"原视频再次分析任务投递失败: {exc}")
|
||||
out.message = "原视频再次分析任务已提交"
|
||||
return out
|
||||
@@ -558,7 +840,38 @@ async def split_custom(
|
||||
|
||||
from app.tasks.shot_replicate_tasks import split_one_segment
|
||||
|
||||
split_one_segment.apply_async(args=[segment_id], queue=CeleryQueue.GEN_SHOT_SPLIT.value, countdown=0)
|
||||
try:
|
||||
split_one_segment.apply_async(args=[segment_id], queue=CeleryQueue.GEN_SHOT_SPLIT.value, countdown=0)
|
||||
except Exception as exc:
|
||||
_log_api_error(
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
current_user=current_user,
|
||||
project_id=task_set_id,
|
||||
step_id=segment_id,
|
||||
message=f"自定义拆镜切片任务投递失败: {exc}",
|
||||
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "split_one_segment"},
|
||||
exc=exc,
|
||||
)
|
||||
try:
|
||||
await mark_custom_segment_split_dispatch_failed(
|
||||
db,
|
||||
current_user=_user_context(current_user),
|
||||
segment_id=segment_id,
|
||||
error_message=f"自定义拆镜切片任务投递失败: {exc}",
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as mark_exc:
|
||||
await db.rollback()
|
||||
_log_api_error(
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
|
||||
current_user=current_user,
|
||||
project_id=task_set_id,
|
||||
step_id=segment_id,
|
||||
message="自定义拆镜切片投递失败后补偿失败",
|
||||
detail={"segment_id": segment_id, "task_set_id": task_set_id},
|
||||
exc=mark_exc,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=f"自定义拆镜切片任务投递失败: {exc}")
|
||||
return out
|
||||
|
||||
|
||||
@@ -627,7 +940,17 @@ async def reanalyze_segment(
|
||||
force=req.force,
|
||||
reason=req.reason,
|
||||
)
|
||||
task_set_id = out.task_set_id
|
||||
task_set_id = str(out.task_set_id)
|
||||
analysis_attempt_no = int(out.analysis_attempt_no)
|
||||
celery_task_id = f"shot-analysis:segment:{segment_id}:attempt:{analysis_attempt_no}"
|
||||
billing_context = _analysis_dispatch_billing_context(
|
||||
user_id=str(current_user.id),
|
||||
owner_id=segment_id,
|
||||
attempt_no=analysis_attempt_no,
|
||||
task_set_id=task_set_id,
|
||||
is_segment=True,
|
||||
celery_task_id=celery_task_id,
|
||||
)
|
||||
await db.commit()
|
||||
except HTTPException as exc:
|
||||
await db.rollback()
|
||||
@@ -653,10 +976,17 @@ async def reanalyze_segment(
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"切片视频再次分析状态重置失败: {exc}")
|
||||
|
||||
log_celery_dispatch_start(billing_context)
|
||||
try:
|
||||
from app.tasks.shot_replicate_tasks import analyze_custom_segment_video
|
||||
|
||||
analyze_custom_segment_video.apply_async(args=[segment_id], queue=CeleryQueue.GEN_SHOT_ANALYSIS.value, countdown=0)
|
||||
analyze_custom_segment_video.apply_async(
|
||||
args=[segment_id],
|
||||
queue=CeleryQueue.GEN_SHOT_ANALYSIS.value,
|
||||
countdown=0,
|
||||
task_id=celery_task_id,
|
||||
)
|
||||
log_celery_dispatch_success(billing_context)
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_SUBMITTED.value,
|
||||
@@ -667,6 +997,7 @@ async def reanalyze_segment(
|
||||
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video", "request": req.model_dump()},
|
||||
)
|
||||
except Exception as exc:
|
||||
log_celery_dispatch_failure(billing_context, error=str(exc))
|
||||
_log_api_error(
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
current_user=current_user,
|
||||
@@ -676,6 +1007,27 @@ async def reanalyze_segment(
|
||||
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video"},
|
||||
exc=exc,
|
||||
)
|
||||
try:
|
||||
compensated = await mark_segment_analysis_dispatch_failed(
|
||||
db,
|
||||
current_user=_user_context(current_user),
|
||||
segment_id=segment_id,
|
||||
error_message=f"切片视频再次分析任务投递失败: {exc}",
|
||||
)
|
||||
await db.commit()
|
||||
if compensated:
|
||||
log_celery_dispatch_compensated(billing_context, error=str(exc))
|
||||
except Exception as mark_exc:
|
||||
await db.rollback()
|
||||
_log_api_error(
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
|
||||
current_user=current_user,
|
||||
project_id=task_set_id,
|
||||
step_id=segment_id,
|
||||
message="切片视频再次分析任务投递失败后补偿失败",
|
||||
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video"},
|
||||
exc=mark_exc,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=f"切片视频再次分析任务投递失败: {exc}")
|
||||
out.message = "切片视频再次分析任务已提交"
|
||||
return out
|
||||
@@ -985,7 +1337,18 @@ async def generate_image_prompt(
|
||||
_ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id)
|
||||
try:
|
||||
project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, material_step_id=step_id, req=req)
|
||||
project_id_value, step_id_value = project.id, step.id
|
||||
project_id_value, step_id_value = str(project.id), str(step.id)
|
||||
user_id_value = str(project.user_id)
|
||||
attempt_no_value = int(step.version or 1)
|
||||
celery_task_id = f"shot-replicate:image-prompt:{step_id_value}"
|
||||
billing_context = _prompt_dispatch_billing_context(
|
||||
user_id=user_id_value,
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
|
||||
attempt_no=attempt_no_value,
|
||||
celery_task_id=celery_task_id,
|
||||
)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
@@ -995,19 +1358,21 @@ async def generate_image_prompt(
|
||||
_log_api_exception_from_locals(exc, locals(), f"提交图片 AI 提词失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"提交图片 AI 提词失败: {exc}")
|
||||
|
||||
try:
|
||||
from app.tasks.shot_replicate_flow_tasks import start_image_prompt_optimize
|
||||
from app.tasks.shot_replicate_flow_tasks import start_image_prompt_optimize
|
||||
|
||||
await register_module_step_task(
|
||||
module=MODULE,
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
|
||||
task_name=TASK_SHOT_IMAGE_PROMPT,
|
||||
)
|
||||
start_image_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue=CeleryQueue.GEN_CHATAPI_CREATE.value, 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"图片 AI 提词任务投递失败: {exc}")
|
||||
log_celery_dispatch_start(billing_context)
|
||||
await _dispatch_prompt_task(
|
||||
db,
|
||||
current_user=current_user,
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
|
||||
task_name=TASK_SHOT_IMAGE_PROMPT,
|
||||
celery_task=start_image_prompt_optimize,
|
||||
celery_task_id=celery_task_id,
|
||||
billing_context=billing_context,
|
||||
error_prefix="图片 AI 提词任务投递失败",
|
||||
)
|
||||
|
||||
return ShotReplicateActionOut(message="图片 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
|
||||
|
||||
@@ -1085,7 +1450,18 @@ async def generate_video_prompt(
|
||||
_ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id)
|
||||
try:
|
||||
project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, image_step_id=step_id, req=req)
|
||||
project_id_value, step_id_value = project.id, step.id
|
||||
project_id_value, step_id_value = str(project.id), str(step.id)
|
||||
user_id_value = str(project.user_id)
|
||||
attempt_no_value = int(step.version or 1)
|
||||
celery_task_id = f"shot-replicate:video-prompt:{step_id_value}"
|
||||
billing_context = _prompt_dispatch_billing_context(
|
||||
user_id=user_id_value,
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
|
||||
attempt_no=attempt_no_value,
|
||||
celery_task_id=celery_task_id,
|
||||
)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
@@ -1095,19 +1471,21 @@ async def generate_video_prompt(
|
||||
_log_api_exception_from_locals(exc, locals(), f"提交视频 AI 提词失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"提交视频 AI 提词失败: {exc}")
|
||||
|
||||
try:
|
||||
from app.tasks.shot_replicate_flow_tasks import start_video_prompt_optimize
|
||||
from app.tasks.shot_replicate_flow_tasks import start_video_prompt_optimize
|
||||
|
||||
await register_module_step_task(
|
||||
module=MODULE,
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
|
||||
task_name=TASK_SHOT_VIDEO_PROMPT,
|
||||
)
|
||||
start_video_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue=CeleryQueue.GEN_CHATAPI_CREATE.value, 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"视频 AI 提词任务投递失败: {exc}")
|
||||
log_celery_dispatch_start(billing_context)
|
||||
await _dispatch_prompt_task(
|
||||
db,
|
||||
current_user=current_user,
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
|
||||
task_name=TASK_SHOT_VIDEO_PROMPT,
|
||||
celery_task=start_video_prompt_optimize,
|
||||
celery_task_id=celery_task_id,
|
||||
billing_context=billing_context,
|
||||
error_prefix="视频 AI 提词任务投递失败",
|
||||
)
|
||||
|
||||
return ShotReplicateActionOut(message="视频 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
|
||||
|
||||
|
||||
@@ -15,12 +15,15 @@ from app.schemas.module_generation_v2 import (
|
||||
)
|
||||
from app.services.generation.pipeline.enqueue_service import enqueue_generation_create
|
||||
from app.services.hot_opening_replicate_service import project_to_detail_out
|
||||
from app.services.llm_billing import LlmBillingContext, log_celery_dispatch_compensated
|
||||
from app.services.module_async_recovery_service import OBJECT_MODULE_STEP, has_live_object_lock
|
||||
from app.services.module_generation_v2.config import HOT_OPENING_V2
|
||||
from app.services.module_generation_v2.dispatch_service import (
|
||||
dispatch_video_prompt_v2,
|
||||
ensure_v2_celery_enabled,
|
||||
)
|
||||
from app.services.module_generation_v2.flow_service import (
|
||||
build_v2_video_prompt_billing_context,
|
||||
create_hot_opening_project_v2,
|
||||
delete_project_v2,
|
||||
generate_video_from_prompt_v2,
|
||||
@@ -35,6 +38,19 @@ from app.services.upload_resource import cleanup_upload_resource_files_after_com
|
||||
router = APIRouter(prefix="/hot-opening-replications", tags=["hot-opening-replications-v2"])
|
||||
|
||||
|
||||
def _dispatch_context(*, user_id: str, project_id: str, step_id: str, step_version: int) -> LlmBillingContext:
|
||||
context = build_v2_video_prompt_billing_context(
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
step_version=step_version,
|
||||
module=HOT_OPENING_V2.module,
|
||||
display_name=HOT_OPENING_V2.display_name,
|
||||
)
|
||||
context.celery_task_id = f"module-v2-video-prompt:{step_id}"
|
||||
return context
|
||||
|
||||
|
||||
async def _detail(db: AsyncSession, current_user: User, project_id: str) -> HotOpeningTaskDetailOut:
|
||||
project = await get_v2_project_for_user(
|
||||
db,
|
||||
@@ -50,14 +66,19 @@ async def _dispatch_or_mark_failed(
|
||||
*,
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
billing_context: LlmBillingContext,
|
||||
) -> None:
|
||||
dispatch = await dispatch_video_prompt_v2(
|
||||
config=HOT_OPENING_V2,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
billing_context=billing_context,
|
||||
)
|
||||
if dispatch.recoverable:
|
||||
return
|
||||
if await has_live_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id):
|
||||
# apply_async 可能已送达但客户端收到异常;worker 已领取时不能释放冻结。
|
||||
return
|
||||
error_message = "视频提词任务的 Redis 注册和 Celery 投递均失败,请重新执行步骤2"
|
||||
await mark_video_prompt_dispatch_failed_v2(
|
||||
db,
|
||||
@@ -66,6 +87,7 @@ async def _dispatch_or_mark_failed(
|
||||
step_id=step_id,
|
||||
error_message=error_message,
|
||||
)
|
||||
log_celery_dispatch_compensated(billing_context, error=error_message)
|
||||
raise HTTPException(status_code=503, detail=error_message)
|
||||
|
||||
|
||||
@@ -81,6 +103,12 @@ async def create_task_v2(
|
||||
project_id = str(result.project.id)
|
||||
step_id = str(result.prompt_step.id)
|
||||
created_new = bool(result.created_new)
|
||||
billing_context = _dispatch_context(
|
||||
user_id=str(result.project.user_id),
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
step_version=int(result.prompt_step.version or 1),
|
||||
)
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
@@ -91,6 +119,12 @@ async def create_task_v2(
|
||||
project_id = str(result.project.id)
|
||||
step_id = str(result.prompt_step.id)
|
||||
created_new = bool(result.created_new)
|
||||
billing_context = _dispatch_context(
|
||||
user_id=str(result.project.user_id),
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
step_version=int(result.prompt_step.version or 1),
|
||||
)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
@@ -100,7 +134,9 @@ async def create_task_v2(
|
||||
raise HTTPException(status_code=500, detail="创建爆款复刻 V2 项目失败") from exc
|
||||
|
||||
if created_new:
|
||||
await _dispatch_or_mark_failed(db, project_id=project_id, step_id=step_id)
|
||||
await _dispatch_or_mark_failed(
|
||||
db, project_id=project_id, step_id=step_id, billing_context=billing_context
|
||||
)
|
||||
return await _detail(db, current_user, project_id)
|
||||
|
||||
|
||||
@@ -136,11 +172,22 @@ async def retry_video_prompt_v2(
|
||||
)
|
||||
project_id_value = str(project.id)
|
||||
step_id_value = str(new_step.id)
|
||||
billing_context = _dispatch_context(
|
||||
user_id=str(project.user_id),
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
step_version=int(new_step.version or 1),
|
||||
)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
await _dispatch_or_mark_failed(db, project_id=project_id_value, step_id=step_id_value)
|
||||
await _dispatch_or_mark_failed(
|
||||
db,
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
billing_context=billing_context,
|
||||
)
|
||||
return HotOpeningActionOut(
|
||||
message="视频提词已重新提交",
|
||||
project_id=project_id_value,
|
||||
|
||||
@@ -14,12 +14,15 @@ from app.schemas.module_generation_v2 import (
|
||||
)
|
||||
from app.schemas.shot_replicate import ShotReplicateActionOut, ShotReplicateDeleteOut, ShotReplicateTaskDetailOut
|
||||
from app.services.generation.pipeline.enqueue_service import enqueue_generation_create
|
||||
from app.services.llm_billing import LlmBillingContext, log_celery_dispatch_compensated
|
||||
from app.services.module_async_recovery_service import OBJECT_MODULE_STEP, has_live_object_lock
|
||||
from app.services.module_generation_v2.config import SHOT_REPLICATE_V2
|
||||
from app.services.module_generation_v2.dispatch_service import (
|
||||
dispatch_video_prompt_v2,
|
||||
ensure_v2_celery_enabled,
|
||||
)
|
||||
from app.services.module_generation_v2.flow_service import (
|
||||
build_v2_video_prompt_billing_context,
|
||||
create_shot_replicate_project_v2,
|
||||
delete_project_v2,
|
||||
generate_video_from_prompt_v2,
|
||||
@@ -36,6 +39,19 @@ from app.services.upload_resource import cleanup_upload_resource_files_after_com
|
||||
router = APIRouter(prefix="/shot-replications", tags=["shot-replications-v2"])
|
||||
|
||||
|
||||
def _dispatch_context(*, user_id: str, project_id: str, step_id: str, step_version: int) -> LlmBillingContext:
|
||||
context = build_v2_video_prompt_billing_context(
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
step_version=step_version,
|
||||
module=SHOT_REPLICATE_V2.module,
|
||||
display_name=SHOT_REPLICATE_V2.display_name,
|
||||
)
|
||||
context.celery_task_id = f"module-v2-video-prompt:{step_id}"
|
||||
return context
|
||||
|
||||
|
||||
async def _detail(db: AsyncSession, current_user: User, project_id: str) -> ShotReplicateTaskDetailOut:
|
||||
project = await get_v2_project_for_user(
|
||||
db,
|
||||
@@ -51,14 +67,19 @@ async def _dispatch_or_mark_failed(
|
||||
*,
|
||||
project_id: str,
|
||||
step_id: str,
|
||||
billing_context: LlmBillingContext,
|
||||
) -> None:
|
||||
dispatch = await dispatch_video_prompt_v2(
|
||||
config=SHOT_REPLICATE_V2,
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
billing_context=billing_context,
|
||||
)
|
||||
if dispatch.recoverable:
|
||||
return
|
||||
if await has_live_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id):
|
||||
# apply_async 可能已送达但客户端收到异常;worker 已领取时不能释放冻结。
|
||||
return
|
||||
error_message = "视频提词任务的 Redis 注册和 Celery 投递均失败,请重新执行步骤2"
|
||||
await mark_video_prompt_dispatch_failed_v2(
|
||||
db,
|
||||
@@ -67,6 +88,7 @@ async def _dispatch_or_mark_failed(
|
||||
step_id=step_id,
|
||||
error_message=error_message,
|
||||
)
|
||||
log_celery_dispatch_compensated(billing_context, error=error_message)
|
||||
raise HTTPException(status_code=503, detail=error_message)
|
||||
|
||||
|
||||
@@ -91,6 +113,12 @@ async def create_project_v2(
|
||||
project_id = str(result.project.id)
|
||||
step_id = str(result.prompt_step.id)
|
||||
created_new = bool(result.created_new)
|
||||
billing_context = _dispatch_context(
|
||||
user_id=str(result.project.user_id),
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
step_version=int(result.prompt_step.version or 1),
|
||||
)
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
@@ -105,6 +133,12 @@ async def create_project_v2(
|
||||
project_id = str(result.project.id)
|
||||
step_id = str(result.prompt_step.id)
|
||||
created_new = bool(result.created_new)
|
||||
billing_context = _dispatch_context(
|
||||
user_id=str(result.project.user_id),
|
||||
project_id=project_id,
|
||||
step_id=step_id,
|
||||
step_version=int(result.prompt_step.version or 1),
|
||||
)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
@@ -114,7 +148,9 @@ async def create_project_v2(
|
||||
raise HTTPException(status_code=500, detail="创建拆镜复刻 V2 项目失败") from exc
|
||||
|
||||
if created_new:
|
||||
await _dispatch_or_mark_failed(db, project_id=project_id, step_id=step_id)
|
||||
await _dispatch_or_mark_failed(
|
||||
db, project_id=project_id, step_id=step_id, billing_context=billing_context
|
||||
)
|
||||
return ShotReplicateActionOut(
|
||||
message="V2 项目已创建,视频提词已自动提交" if created_new else "已返回现有幂等项目",
|
||||
project_id=project_id,
|
||||
@@ -155,11 +191,22 @@ async def retry_video_prompt_v2(
|
||||
)
|
||||
project_id_value = str(project.id)
|
||||
step_id_value = str(new_step.id)
|
||||
billing_context = _dispatch_context(
|
||||
user_id=str(project.user_id),
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
step_version=int(new_step.version or 1),
|
||||
)
|
||||
await db.commit()
|
||||
except HTTPException:
|
||||
await db.rollback()
|
||||
raise
|
||||
await _dispatch_or_mark_failed(db, project_id=project_id_value, step_id=step_id_value)
|
||||
await _dispatch_or_mark_failed(
|
||||
db,
|
||||
project_id=project_id_value,
|
||||
step_id=step_id_value,
|
||||
billing_context=billing_context,
|
||||
)
|
||||
return ShotReplicateActionOut(
|
||||
message="视频提词已重新提交",
|
||||
project_id=project_id_value,
|
||||
|
||||
Reference in New Issue
Block a user