积分冻结释放
This commit is contained in:
@@ -8,7 +8,8 @@ from typing import Any
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.config import settings
|
||||
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordOwnerType
|
||||
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType, CreditRecordSourceModule, CreditRecordSourceStepCode
|
||||
from app.enums.llm_billing import LlmBillingConfigKey
|
||||
from app.enums.celery_queue import CeleryQueue, CeleryTaskName
|
||||
from app.enums.celery_runtime import CeleryRuntimeDomain
|
||||
from app.enums.shot_replicate import (
|
||||
@@ -34,12 +35,21 @@ from app.services.celery_runtime.recovery_service import guard_periodic_recovery
|
||||
from app.services.celery_runtime.runtime_service import CeleryRuntimeLease, RuntimeIdentity
|
||||
from app.services.shot_replicate_taskset_service import refresh_task_set_split_summary
|
||||
from app.services.shot_video_analysis_service import analyze_video_for_shot_split
|
||||
from app.services.generation.billing_service import charge_shot_video_analysis_usage
|
||||
from app.services.llm_billing import (
|
||||
LlmBillingContext,
|
||||
ensure_hold_exists,
|
||||
log_provider_failure,
|
||||
log_provider_start,
|
||||
log_provider_success,
|
||||
release_on_failure,
|
||||
settle_success,
|
||||
)
|
||||
from app.services.shot_video_split_service import cleanup_split_result, finalize_split_result, split_video_segment_async
|
||||
from app.services.upload_video_asset_service import validate_split_range
|
||||
from app.services.upload_resource import record_shot_segment_upload_resource
|
||||
from app.tasks.async_runner import run_async
|
||||
from app.tasks.celery_app import celery_app
|
||||
from app.utils.exceptions import InsufficientCreditsError
|
||||
|
||||
logger = logging.getLogger("video_gen")
|
||||
|
||||
@@ -187,6 +197,40 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
|
||||
task_set.analysis_started_at = _now()
|
||||
task_set.analysis_lease_until = _now() + timedelta(seconds=int(settings.SHOT_ANALYSIS_LEASE_SECONDS or 180))
|
||||
task_set.analysis_error_message = None
|
||||
llm_billing_context = LlmBillingContext(
|
||||
user_id=task_set_user_id,
|
||||
owner_type=CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.value,
|
||||
owner_id=task_set_id,
|
||||
attempt_no=attempt_no,
|
||||
charge_kind=CreditRecordChargeKind.VIDEO_ANALYSIS.value,
|
||||
billing_scene=CreditRecordBillingScene.SHOT_ORIGINAL_VIDEO_ANALYSIS.value,
|
||||
source_module=CreditRecordSourceModule.SHOT_REPLICATE.value,
|
||||
source_project_id=task_set_id,
|
||||
source_step_id=task_set_id,
|
||||
source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value,
|
||||
related_id=task_set_id,
|
||||
hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
|
||||
description_prefix="拆镜复刻原视频分析",
|
||||
trace_id=f"shot-task-set-analysis:{task_set_id}:attempt:{attempt_no}",
|
||||
)
|
||||
hold_validation = await ensure_hold_exists(db, llm_billing_context)
|
||||
if not hold_validation.can_execute:
|
||||
error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止原视频分析任务"
|
||||
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value
|
||||
task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value
|
||||
task_set.analysis_claim_token = None
|
||||
task_set.analysis_lease_until = None
|
||||
task_set.analysis_error_message = error_message
|
||||
await db.commit()
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.ANALYSIS_FAILED.value,
|
||||
project_id=task_set_id,
|
||||
user_id=task_set_user_id,
|
||||
message=error_message,
|
||||
detail={"task_set_id": task_set_id, "analysis_attempt_no": attempt_no},
|
||||
)
|
||||
return
|
||||
await db.commit()
|
||||
|
||||
log_module_event_file(
|
||||
@@ -204,6 +248,12 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
|
||||
},
|
||||
)
|
||||
|
||||
provider_succeeded = False
|
||||
analyzed = None
|
||||
log_provider_start(
|
||||
llm_billing_context,
|
||||
detail={"analysis_mode": "full_breakdown", "video_url": video_url},
|
||||
)
|
||||
async with async_session() as call_db:
|
||||
analyzed = await analyze_video_for_shot_split(
|
||||
call_db,
|
||||
@@ -213,6 +263,8 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
|
||||
task_set_id=task_set_id,
|
||||
trace_id=f"shot-task-set-analysis:{task_set_id}:attempt:{attempt_no}",
|
||||
)
|
||||
provider_succeeded = True
|
||||
log_provider_success(llm_billing_context, usage=analyzed.usage)
|
||||
await lease.ensure_owned()
|
||||
result = await call_db.execute(
|
||||
select(ShotReplicateTaskSet)
|
||||
@@ -229,6 +281,14 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
|
||||
or task_set.analysis_status != ShotAnalysisStatusEnum.PROCESSING.value
|
||||
):
|
||||
await call_db.rollback()
|
||||
# Provider 已成功,旧业务对象失效也必须按真实 usage 结算。
|
||||
await settle_success(
|
||||
call_db,
|
||||
llm_billing_context,
|
||||
usage=analyzed.usage,
|
||||
description="拆镜复刻-原视频分析(失效结果结算)",
|
||||
)
|
||||
await call_db.commit()
|
||||
return
|
||||
result_json = analyzed.result
|
||||
task_set.original_video_content = str(result_json.get("原视频内容") or "无")
|
||||
@@ -242,16 +302,11 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
|
||||
task_set.analysis_claim_token = None
|
||||
task_set.analysis_lease_until = None
|
||||
task_set.analysis_error_message = None
|
||||
await charge_shot_video_analysis_usage(
|
||||
await settle_success(
|
||||
call_db,
|
||||
user_id=task_set.user_id,
|
||||
owner_type=CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.value,
|
||||
owner_id=task_set.id,
|
||||
llm_billing_context,
|
||||
usage=analyzed.usage,
|
||||
description="拆镜复刻-原视频分析",
|
||||
billing_scene=CreditRecordBillingScene.SHOT_ORIGINAL_VIDEO_ANALYSIS.value,
|
||||
source_project_id=task_set.id,
|
||||
attempt_no=attempt_no,
|
||||
)
|
||||
await call_db.commit()
|
||||
|
||||
@@ -277,6 +332,8 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
|
||||
except RedisExecutionLockError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if "llm_billing_context" in locals() and not locals().get("provider_succeeded", False):
|
||||
log_provider_failure(llm_billing_context, error=str(exc))
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(ShotReplicateTaskSet)
|
||||
@@ -285,21 +342,32 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
|
||||
.limit(1)
|
||||
)
|
||||
task_set = result.scalar_one_or_none()
|
||||
if (
|
||||
task_set_is_current = bool(
|
||||
task_set
|
||||
and int(task_set.analysis_attempt_no or 1) == attempt_no
|
||||
and task_set.analysis_claim_token == token
|
||||
and task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value
|
||||
):
|
||||
)
|
||||
if task_set_is_current and task_set is not None:
|
||||
task_set_user_id = task_set_user_id or str(task_set.user_id)
|
||||
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value
|
||||
task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value
|
||||
task_set.analysis_claim_token = None
|
||||
task_set.analysis_lease_until = None
|
||||
task_set.analysis_error_message = str(exc)
|
||||
await db.commit()
|
||||
else:
|
||||
await db.rollback()
|
||||
# 业务对象是否仍有效,不影响本 attempt 的账务终态:provider 已成功必须结算,
|
||||
# provider 未成功则幂等释放。这样人工删库/异常换 attempt 也不会遗留 active HOLD。
|
||||
if "llm_billing_context" in locals():
|
||||
if locals().get("provider_succeeded", False) and locals().get("analyzed") is not None:
|
||||
await settle_success(
|
||||
db,
|
||||
llm_billing_context,
|
||||
usage=analyzed.usage,
|
||||
description="拆镜复刻-原视频分析(本地失败结算)",
|
||||
)
|
||||
else:
|
||||
await release_on_failure(db, llm_billing_context, error=str(exc))
|
||||
await db.commit()
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.ANALYSIS_FAILED.value,
|
||||
@@ -379,6 +447,40 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None:
|
||||
segment.analysis_started_at = _now()
|
||||
segment.analysis_lease_until = _now() + timedelta(seconds=int(settings.SHOT_ANALYSIS_LEASE_SECONDS or 180))
|
||||
segment.analysis_error_message = None
|
||||
llm_billing_context = LlmBillingContext(
|
||||
user_id=user_id,
|
||||
owner_type=CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value,
|
||||
owner_id=segment_id,
|
||||
attempt_no=attempt_no,
|
||||
charge_kind=CreditRecordChargeKind.VIDEO_ANALYSIS.value,
|
||||
billing_scene=CreditRecordBillingScene.SHOT_SEGMENT_VIDEO_ANALYSIS.value,
|
||||
source_module=CreditRecordSourceModule.SHOT_REPLICATE.value,
|
||||
source_project_id=task_set_id,
|
||||
source_step_id=segment_id,
|
||||
source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value,
|
||||
related_id=segment_id,
|
||||
hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
|
||||
description_prefix="拆镜复刻片段视频分析",
|
||||
trace_id=f"shot-segment-analysis:{segment_id}:attempt:{attempt_no}",
|
||||
)
|
||||
hold_validation = await ensure_hold_exists(db, llm_billing_context)
|
||||
if not hold_validation.can_execute:
|
||||
error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止片段视频分析任务"
|
||||
segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
|
||||
segment.analysis_claim_token = None
|
||||
segment.analysis_lease_until = None
|
||||
segment.analysis_error_message = error_message
|
||||
await db.commit()
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_FAILED.value,
|
||||
project_id=task_set_id,
|
||||
step_id=segment_id,
|
||||
user_id=user_id,
|
||||
message=error_message,
|
||||
detail={"segment_id": segment_id, "task_set_id": task_set_id, "analysis_attempt_no": attempt_no},
|
||||
)
|
||||
return
|
||||
await db.commit()
|
||||
|
||||
log_module_event_file(
|
||||
@@ -391,6 +493,12 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None:
|
||||
detail={"segment_id": segment_id, "task_set_id": task_set_id, "video_url": video_url, "analysis_attempt_no": attempt_no},
|
||||
)
|
||||
|
||||
provider_succeeded = False
|
||||
analyzed = None
|
||||
log_provider_start(
|
||||
llm_billing_context,
|
||||
detail={"analysis_mode": "summary_only", "video_url": video_url},
|
||||
)
|
||||
async with async_session() as call_db:
|
||||
analyzed = await analyze_video_for_shot_split(
|
||||
call_db,
|
||||
@@ -401,6 +509,8 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None:
|
||||
segment_id=segment_id,
|
||||
trace_id=f"shot-segment-analysis:{segment_id}:attempt:{attempt_no}",
|
||||
)
|
||||
provider_succeeded = True
|
||||
log_provider_success(llm_billing_context, usage=analyzed.usage)
|
||||
await lease.ensure_owned()
|
||||
result = await call_db.execute(
|
||||
select(ShotReplicateSegment)
|
||||
@@ -417,6 +527,13 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None:
|
||||
or segment.analysis_status != ShotSegmentAnalysisStatusEnum.PROCESSING.value
|
||||
):
|
||||
await call_db.rollback()
|
||||
await settle_success(
|
||||
call_db,
|
||||
llm_billing_context,
|
||||
usage=analyzed.usage,
|
||||
description="拆镜复刻-片段视频分析(失效结果结算)",
|
||||
)
|
||||
await call_db.commit()
|
||||
return
|
||||
result_json = analyzed.result
|
||||
segment.original_video_content = str(result_json.get("原视频内容") or "无")
|
||||
@@ -430,17 +547,11 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None:
|
||||
segment.analysis_claim_token = None
|
||||
segment.analysis_lease_until = None
|
||||
segment.analysis_error_message = None
|
||||
await charge_shot_video_analysis_usage(
|
||||
await settle_success(
|
||||
call_db,
|
||||
user_id=segment.user_id,
|
||||
owner_type=CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value,
|
||||
owner_id=segment.id,
|
||||
llm_billing_context,
|
||||
usage=analyzed.usage,
|
||||
description="拆镜复刻-片段视频分析",
|
||||
billing_scene=CreditRecordBillingScene.SHOT_SEGMENT_VIDEO_ANALYSIS.value,
|
||||
source_project_id=segment.task_set_id,
|
||||
source_step_id=segment.id,
|
||||
attempt_no=attempt_no,
|
||||
)
|
||||
await call_db.commit()
|
||||
|
||||
@@ -467,6 +578,8 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None:
|
||||
except RedisExecutionLockError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if "llm_billing_context" in locals() and not locals().get("provider_succeeded", False):
|
||||
log_provider_failure(llm_billing_context, error=str(exc))
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(ShotReplicateSegment)
|
||||
@@ -475,21 +588,30 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None:
|
||||
.limit(1)
|
||||
)
|
||||
segment = result.scalar_one_or_none()
|
||||
if (
|
||||
segment_is_current = bool(
|
||||
segment
|
||||
and int(segment.analysis_attempt_no or 1) == attempt_no
|
||||
and segment.analysis_claim_token == token
|
||||
and segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value
|
||||
):
|
||||
)
|
||||
if segment_is_current and segment is not None:
|
||||
user_id = user_id or str(segment.user_id)
|
||||
task_set_id = task_set_id or str(segment.task_set_id)
|
||||
segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
|
||||
segment.analysis_claim_token = None
|
||||
segment.analysis_lease_until = None
|
||||
segment.analysis_error_message = str(exc)
|
||||
await db.commit()
|
||||
else:
|
||||
await db.rollback()
|
||||
if "llm_billing_context" in locals():
|
||||
if locals().get("provider_succeeded", False) and locals().get("analyzed") is not None:
|
||||
await settle_success(
|
||||
db,
|
||||
llm_billing_context,
|
||||
usage=analyzed.usage,
|
||||
description="拆镜复刻-片段视频分析(本地失败结算)",
|
||||
)
|
||||
else:
|
||||
await release_on_failure(db, llm_billing_context, error=str(exc))
|
||||
await db.commit()
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_FAILED.value,
|
||||
@@ -761,6 +883,8 @@ async def _run_split_one_segment(segment_id: str) -> None:
|
||||
segment.split_status = ShotSplitStatusEnum.FAILED.value
|
||||
segment.split_next_retry_at = None
|
||||
final_failed = True
|
||||
# 切片最终失败不释放片段分析 HOLD;手动切片重试继续沿用
|
||||
# 原 attempt。用户最终删除片段/任务集时再做取消补偿。
|
||||
else:
|
||||
segment.split_status = ShotSplitStatusEnum.RETRY_WAITING.value
|
||||
segment.split_next_retry_at = _retry_at(attempt)
|
||||
|
||||
Reference in New Issue
Block a user