会员积分改版V1
This commit is contained in:
@@ -34,6 +34,7 @@ CELERY_TASK_IMPORTS = (
|
||||
"app.tasks.module_generation_v2_tasks",
|
||||
"app.tasks.private_portrait_asset_tasks",
|
||||
"app.tasks.celery_runtime_tasks",
|
||||
"app.tasks.credit_tasks",
|
||||
)
|
||||
|
||||
|
||||
@@ -120,6 +121,11 @@ def _beat_schedule() -> dict:
|
||||
"schedule": 300,
|
||||
"options": {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
|
||||
}
|
||||
schedule["credit-maintenance-every-minute"] = {
|
||||
"task": CeleryTaskName.CREDIT_MAINTENANCE.value,
|
||||
"schedule": 60,
|
||||
"options": {"queue": CeleryQueue.GEN_CREDIT_MAINTENANCE.value},
|
||||
}
|
||||
return schedule
|
||||
|
||||
|
||||
@@ -181,6 +187,7 @@ if broker_url:
|
||||
CeleryTaskName.MODULE_ASYNC_RECOVERY.value: {"ignore_result": True},
|
||||
CeleryTaskName.SHOT_ANALYSIS_RECOVERY.value: {"ignore_result": True},
|
||||
CeleryTaskName.SHOT_SPLIT_RECOVERY.value: {"ignore_result": True},
|
||||
CeleryTaskName.CREDIT_MAINTENANCE.value: {"ignore_result": True},
|
||||
},
|
||||
worker_prefetch_multiplier=1,
|
||||
worker_cancel_long_running_tasks_on_connection_loss=True,
|
||||
@@ -240,6 +247,7 @@ if broker_url:
|
||||
CeleryTaskName.PRIVATE_PORTRAIT_DELETE_GROUP.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
|
||||
CeleryTaskName.PRIVATE_PORTRAIT_DELETE_PROJECT.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
|
||||
CeleryTaskName.PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
|
||||
CeleryTaskName.CREDIT_MAINTENANCE.value: {"queue": CeleryQueue.GEN_CREDIT_MAINTENANCE.value},
|
||||
},
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.models.base import async_session
|
||||
from app.services.credit.expiration_service import (
|
||||
archive_expired_user_balances,
|
||||
list_expired_balance_user_limits,
|
||||
)
|
||||
from app.services.credit.subscription_service import (
|
||||
expire_subscription_by_id,
|
||||
grant_due_subscription_period_by_id,
|
||||
list_due_subscription_ids,
|
||||
list_due_subscription_period_candidates,
|
||||
)
|
||||
from app.services.credit.utils import utc_now
|
||||
from app.services.operation_log_service import log_operation_event
|
||||
from app.tasks.async_runner import run_async
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
|
||||
async def _run_credit_maintenance_once(batch_size: int = 500) -> dict[str, Any]:
|
||||
checked_at = utc_now()
|
||||
limit = max(1, min(int(batch_size or 500), 2000))
|
||||
errors: list[dict[str, str]] = []
|
||||
log_operation_event(
|
||||
domain="billing",
|
||||
module="credit_maintenance",
|
||||
event_type="CREDIT_MAINTENANCE_STARTED",
|
||||
event_status="started",
|
||||
source="app.tasks.credit_tasks._run_credit_maintenance_once",
|
||||
message="积分维护批次开始",
|
||||
detail={"checked_at": checked_at.isoformat(), "batch_size": limit},
|
||||
)
|
||||
|
||||
async with async_session() as scan_db:
|
||||
grant_candidates = await list_due_subscription_period_candidates(
|
||||
scan_db, request_time=checked_at, batch_size=limit
|
||||
)
|
||||
subscription_ids = await list_due_subscription_ids(
|
||||
scan_db, request_time=checked_at, batch_size=limit
|
||||
)
|
||||
expired_user_limits = await list_expired_balance_user_limits(
|
||||
scan_db, request_time=checked_at, batch_size=limit
|
||||
)
|
||||
await scan_db.rollback()
|
||||
|
||||
def record_failure(stage: str, item_id: str, exc: Exception) -> None:
|
||||
errors.append({"stage": stage, "id": item_id, "error": str(exc)})
|
||||
log_operation_event(
|
||||
domain="billing",
|
||||
module="credit_maintenance",
|
||||
event_type="CREDIT_MAINTENANCE_ITEM_FAILED",
|
||||
event_status="failed",
|
||||
source="app.tasks.credit_tasks._run_credit_maintenance_once",
|
||||
task_id=item_id,
|
||||
message="积分维护单条处理失败",
|
||||
error=str(exc),
|
||||
detail={"stage": stage, "item_id": item_id},
|
||||
)
|
||||
|
||||
granted = 0
|
||||
for period_id, subscription_id, user_id in grant_candidates:
|
||||
async with async_session() as db:
|
||||
try:
|
||||
changed = await grant_due_subscription_period_by_id(
|
||||
db,
|
||||
period_id=period_id,
|
||||
subscription_id=subscription_id,
|
||||
user_id=user_id,
|
||||
request_time=checked_at,
|
||||
)
|
||||
await db.commit()
|
||||
granted += int(changed)
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
record_failure("subscription_grant", period_id, exc)
|
||||
|
||||
expired_subscriptions = 0
|
||||
for subscription_id in subscription_ids:
|
||||
async with async_session() as db:
|
||||
try:
|
||||
changed = await expire_subscription_by_id(
|
||||
db, subscription_id=subscription_id, request_time=checked_at
|
||||
)
|
||||
await db.commit()
|
||||
expired_subscriptions += int(changed)
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
record_failure("subscription_expire", subscription_id, exc)
|
||||
|
||||
expired = 0
|
||||
for user_id, user_limit in expired_user_limits:
|
||||
async with async_session() as db:
|
||||
try:
|
||||
changed = await archive_expired_user_balances(
|
||||
db, user_id=user_id, request_time=checked_at, limit=user_limit
|
||||
)
|
||||
await db.commit()
|
||||
expired += int(changed)
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
record_failure("credit_expire", user_id, exc)
|
||||
|
||||
result = {
|
||||
"checked_at": checked_at.isoformat(),
|
||||
"subscription_periods_granted": granted,
|
||||
"subscriptions_expired": expired_subscriptions,
|
||||
"credit_balances_archived": expired,
|
||||
"failed_count": len(errors),
|
||||
"errors": errors[:50],
|
||||
}
|
||||
log_operation_event(
|
||||
domain="billing",
|
||||
module="credit_maintenance",
|
||||
event_type="CREDIT_MAINTENANCE_COMPLETED",
|
||||
event_status="failed" if errors else "success",
|
||||
source="app.tasks.credit_tasks._run_credit_maintenance_once",
|
||||
message="积分维护批次完成",
|
||||
detail=result,
|
||||
error=(f"{len(errors)} 条处理失败" if errors else None),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
if celery_app:
|
||||
|
||||
@celery_app.task(
|
||||
name="credit.maintenance_once",
|
||||
bind=True,
|
||||
soft_time_limit=540,
|
||||
time_limit=600,
|
||||
ignore_result=True,
|
||||
)
|
||||
def credit_maintenance_once(self, batch_size: int = 500) -> dict[str, Any]:
|
||||
return run_async(_run_credit_maintenance_once(batch_size))
|
||||
|
||||
else:
|
||||
|
||||
class _DisabledTask:
|
||||
def delay(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise RuntimeError("Celery is disabled")
|
||||
|
||||
def apply_async(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise RuntimeError("Celery is disabled")
|
||||
|
||||
credit_maintenance_once = _DisabledTask()
|
||||
@@ -9,7 +9,6 @@ from sqlalchemy import select, update
|
||||
|
||||
from app.config import settings
|
||||
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 (
|
||||
@@ -40,19 +39,19 @@ from app.services.shot_replicate_taskset_service import (
|
||||
from app.services.shot_video_analysis_service import analyze_video_for_shot_split
|
||||
from app.services.llm_billing import (
|
||||
LlmBillingContext,
|
||||
ensure_hold_exists,
|
||||
ensure_pre_deducted,
|
||||
log_provider_failure,
|
||||
log_provider_start,
|
||||
log_provider_success,
|
||||
release_on_failure,
|
||||
settle_success,
|
||||
mark_business_success,
|
||||
record_provider_exception,
|
||||
finalize_llm_business_failure,
|
||||
)
|
||||
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")
|
||||
|
||||
@@ -170,7 +169,7 @@ async def _persist_original_analysis_result(
|
||||
billing_context: LlmBillingContext,
|
||||
allow_business_write: bool,
|
||||
) -> str:
|
||||
"""持久化原视频分析结果并完成账务;供应商成功后禁止再次调用模型。"""
|
||||
"""持久化原视频分析结果;失效结果按最终失败退款,禁止错误结算成功。"""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(ShotReplicateTaskSet)
|
||||
@@ -190,32 +189,35 @@ async def _persist_original_analysis_result(
|
||||
and str(task_set.video_url) == str(video_url)
|
||||
and task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value
|
||||
)
|
||||
if is_current and task_set is not None:
|
||||
result_json = analyzed.result
|
||||
task_set.original_video_content = str(result_json.get("原视频内容") or "无")
|
||||
task_set.original_video_category = str(result_json.get("原视频分类") or "无")
|
||||
task_set.original_video_audience = str(result_json.get("原视频受众人群") or "无")
|
||||
task_set.ai_suggestion_json = result_json.get("拆镜内容剖析") or []
|
||||
task_set.analysis_raw_json = analyzed.raw_response
|
||||
task_set.analysis_result_json = result_json
|
||||
task_set.analysis_status = ShotAnalysisStatusEnum.COMPLETED.value
|
||||
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_COMPLETED.value
|
||||
task_set.analysis_claim_token = None
|
||||
task_set.analysis_lease_until = None
|
||||
task_set.analysis_error_message = None
|
||||
description = "拆镜复刻-原视频分析"
|
||||
outcome = "completed"
|
||||
else:
|
||||
description = "拆镜复刻-原视频分析(失效结果结算)"
|
||||
outcome = "stale_settled"
|
||||
await settle_success(
|
||||
if not is_current or task_set is None:
|
||||
await db.rollback()
|
||||
await log_provider_failure(db, billing_context, error="原视频分析业务 attempt 已失效,供应商结果被丢弃")
|
||||
await finalize_llm_business_failure(
|
||||
billing_context,
|
||||
error="原视频分析业务 attempt 已失效,供应商结果被丢弃",
|
||||
)
|
||||
return "stale_refunded"
|
||||
|
||||
result_json = analyzed.result
|
||||
task_set.original_video_content = str(result_json.get("原视频内容") or "无")
|
||||
task_set.original_video_category = str(result_json.get("原视频分类") or "无")
|
||||
task_set.original_video_audience = str(result_json.get("原视频受众人群") or "无")
|
||||
task_set.ai_suggestion_json = result_json.get("拆镜内容剖析") or []
|
||||
task_set.analysis_raw_json = analyzed.raw_response
|
||||
task_set.analysis_result_json = result_json
|
||||
task_set.analysis_status = ShotAnalysisStatusEnum.COMPLETED.value
|
||||
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_COMPLETED.value
|
||||
task_set.analysis_claim_token = None
|
||||
task_set.analysis_lease_until = None
|
||||
task_set.analysis_error_message = None
|
||||
await mark_business_success(
|
||||
db,
|
||||
billing_context,
|
||||
usage=analyzed.usage,
|
||||
description=description,
|
||||
description="拆镜复刻-原视频分析",
|
||||
)
|
||||
await db.commit()
|
||||
return outcome
|
||||
return "completed"
|
||||
|
||||
|
||||
async def _persist_segment_analysis_result(
|
||||
@@ -228,7 +230,7 @@ async def _persist_segment_analysis_result(
|
||||
billing_context: LlmBillingContext,
|
||||
allow_business_write: bool,
|
||||
) -> str:
|
||||
"""持久化自定义切片分析结果并完成账务;供应商成功后禁止再次调用模型。"""
|
||||
"""持久化切片分析结果;失效结果按最终失败退款,禁止错误结算成功。"""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(ShotReplicateSegment)
|
||||
@@ -248,103 +250,102 @@ async def _persist_segment_analysis_result(
|
||||
and str(segment.segment_video_url) == str(video_url)
|
||||
and segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value
|
||||
)
|
||||
if is_current and segment is not None:
|
||||
result_json = analyzed.result
|
||||
segment.original_video_content = str(result_json.get("原视频内容") or "无")
|
||||
segment.original_video_category = str(result_json.get("原视频分类") or "无")
|
||||
segment.original_video_audience = str(result_json.get("原视频受众人群") or "无")
|
||||
segment.segment_content = segment.original_video_content
|
||||
segment.segment_category = segment.original_video_category
|
||||
segment.segment_audience = segment.original_video_audience
|
||||
segment.analysis_json = result_json
|
||||
segment.analysis_status = ShotSegmentAnalysisStatusEnum.COMPLETED.value
|
||||
segment.analysis_claim_token = None
|
||||
segment.analysis_lease_until = None
|
||||
segment.analysis_error_message = None
|
||||
description = "拆镜复刻-片段视频分析"
|
||||
outcome = "completed"
|
||||
else:
|
||||
description = "拆镜复刻-片段视频分析(失效结果结算)"
|
||||
outcome = "stale_settled"
|
||||
await settle_success(
|
||||
if not is_current or segment is None:
|
||||
await db.rollback()
|
||||
await log_provider_failure(db, billing_context, error="片段视频分析业务 attempt 已失效,供应商结果被丢弃")
|
||||
await finalize_llm_business_failure(
|
||||
billing_context,
|
||||
error="片段视频分析业务 attempt 已失效,供应商结果被丢弃",
|
||||
)
|
||||
return "stale_refunded"
|
||||
|
||||
result_json = analyzed.result
|
||||
segment.original_video_content = str(result_json.get("原视频内容") or "无")
|
||||
segment.original_video_category = str(result_json.get("原视频分类") or "无")
|
||||
segment.original_video_audience = str(result_json.get("原视频受众人群") or "无")
|
||||
segment.segment_content = segment.original_video_content
|
||||
segment.segment_category = segment.original_video_category
|
||||
segment.segment_audience = segment.original_video_audience
|
||||
segment.analysis_json = result_json
|
||||
segment.analysis_status = ShotSegmentAnalysisStatusEnum.COMPLETED.value
|
||||
segment.analysis_claim_token = None
|
||||
segment.analysis_lease_until = None
|
||||
segment.analysis_error_message = None
|
||||
await mark_business_success(
|
||||
db,
|
||||
billing_context,
|
||||
usage=analyzed.usage,
|
||||
description=description,
|
||||
description="拆镜复刻-片段视频分析",
|
||||
)
|
||||
await db.commit()
|
||||
return outcome
|
||||
return "completed"
|
||||
|
||||
|
||||
async def _mark_original_provider_success_pending_manual(
|
||||
async def _finalize_original_analysis_failure(
|
||||
*,
|
||||
task_set_id: str,
|
||||
attempt_no: int,
|
||||
token: str,
|
||||
error_message: str,
|
||||
) -> bool:
|
||||
"""供应商已成功但本地结算失败:终止自动恢复,保留 HOLD 等待人工对账。"""
|
||||
billing_context: LlmBillingContext,
|
||||
error: str,
|
||||
) -> None:
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(ShotReplicateTaskSet)
|
||||
.where(
|
||||
ShotReplicateTaskSet.id == task_set_id,
|
||||
ShotReplicateTaskSet.deleted_at.is_(None),
|
||||
)
|
||||
.where(ShotReplicateTaskSet.id == task_set_id, ShotReplicateTaskSet.deleted_at.is_(None))
|
||||
.with_for_update()
|
||||
.limit(1)
|
||||
)
|
||||
task_set = result.scalar_one_or_none()
|
||||
if not (
|
||||
if (
|
||||
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
|
||||
):
|
||||
await db.rollback()
|
||||
return False
|
||||
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
|
||||
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(error)[:1000]
|
||||
await db.commit()
|
||||
return True
|
||||
await finalize_llm_business_failure(billing_context, error=error)
|
||||
|
||||
|
||||
async def _mark_segment_provider_success_pending_manual(
|
||||
async def _finalize_segment_analysis_failure(
|
||||
*,
|
||||
segment_id: str,
|
||||
attempt_no: int,
|
||||
token: str,
|
||||
error_message: str,
|
||||
) -> bool:
|
||||
"""供应商已成功但本地结算失败:终止自动恢复,保留 HOLD 等待人工对账。"""
|
||||
billing_context: LlmBillingContext,
|
||||
error: str,
|
||||
) -> tuple[str | None, str | None]:
|
||||
user_id: str | None = None
|
||||
task_set_id: str | None = None
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(ShotReplicateSegment)
|
||||
.where(
|
||||
ShotReplicateSegment.id == segment_id,
|
||||
ShotReplicateSegment.deleted_at.is_(None),
|
||||
)
|
||||
.where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None))
|
||||
.with_for_update()
|
||||
.limit(1)
|
||||
)
|
||||
segment = result.scalar_one_or_none()
|
||||
if not (
|
||||
if segment:
|
||||
user_id = str(segment.user_id)
|
||||
task_set_id = str(segment.task_set_id)
|
||||
if (
|
||||
segment
|
||||
and int(segment.analysis_attempt_no or 1) == attempt_no
|
||||
and segment.analysis_claim_token == token
|
||||
and segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value
|
||||
):
|
||||
await db.rollback()
|
||||
return False
|
||||
segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
|
||||
segment.analysis_claim_token = None
|
||||
segment.analysis_lease_until = None
|
||||
segment.analysis_error_message = error_message
|
||||
segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
|
||||
segment.analysis_claim_token = None
|
||||
segment.analysis_lease_until = None
|
||||
segment.analysis_error_message = str(error)[:1000]
|
||||
await db.commit()
|
||||
return True
|
||||
await finalize_llm_business_failure(billing_context, error=error)
|
||||
return user_id, task_set_id
|
||||
|
||||
|
||||
async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int | None) -> None:
|
||||
@@ -470,13 +471,12 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int
|
||||
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="拆镜复刻原视频分析",
|
||||
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}),已终止原视频分析任务"
|
||||
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
|
||||
if not pre_deduct_validation.can_execute:
|
||||
error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止原视频分析任务"
|
||||
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value
|
||||
task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value
|
||||
task_set.analysis_claim_token = None
|
||||
@@ -511,7 +511,7 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int
|
||||
|
||||
provider_succeeded = False
|
||||
analyzed = None
|
||||
log_provider_start(
|
||||
await log_provider_start(db,
|
||||
llm_billing_context,
|
||||
detail={"analysis_mode": "full_breakdown", "video_url": video_url},
|
||||
)
|
||||
@@ -523,9 +523,11 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int
|
||||
mode="full_breakdown",
|
||||
task_set_id=task_set_id,
|
||||
trace_id=f"shot-task-set-analysis:{task_set_id}:attempt:{attempt_no}",
|
||||
fixed_model_config_id=llm_billing_context.model_config_id,
|
||||
fixed_model_snapshot=llm_billing_context.model_parameters_snapshot,
|
||||
)
|
||||
provider_succeeded = True
|
||||
log_provider_success(llm_billing_context, usage=analyzed.usage)
|
||||
await log_provider_success(db, llm_billing_context, usage=analyzed.usage)
|
||||
try:
|
||||
await lease.ensure_owned()
|
||||
allow_business_write = True
|
||||
@@ -566,8 +568,11 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int
|
||||
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))
|
||||
if "llm_billing_context" in locals():
|
||||
if locals().get("provider_succeeded", False):
|
||||
await log_provider_failure(db, llm_billing_context, error=str(exc))
|
||||
else:
|
||||
provider_succeeded, _usage = await record_provider_exception(db, llm_billing_context, exc)
|
||||
if "llm_billing_context" in locals() and locals().get("provider_succeeded", False) and locals().get("analyzed") is not None:
|
||||
try:
|
||||
try:
|
||||
@@ -595,40 +600,16 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int
|
||||
)
|
||||
return
|
||||
except Exception as settlement_exc:
|
||||
manual_error = (
|
||||
"供应商已成功,但本地结果保存或积分结算失败;已终止自动恢复并保留冻结积分,"
|
||||
f"需人工对账。error={settlement_exc}"
|
||||
)
|
||||
await _mark_original_provider_success_pending_manual(
|
||||
task_set_id=task_set_id,
|
||||
attempt_no=attempt_no,
|
||||
token=token,
|
||||
error_message=manual_error,
|
||||
)
|
||||
await log_provider_failure(db, llm_billing_context, error=str(settlement_exc))
|
||||
exc = settlement_exc
|
||||
elif "llm_billing_context" in locals():
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(ShotReplicateTaskSet)
|
||||
.where(ShotReplicateTaskSet.id == task_set_id, ShotReplicateTaskSet.deleted_at.is_(None))
|
||||
.with_for_update()
|
||||
.limit(1)
|
||||
)
|
||||
task_set = result.scalar_one_or_none()
|
||||
if (
|
||||
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
|
||||
):
|
||||
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 release_on_failure(db, llm_billing_context, error=str(exc))
|
||||
await db.commit()
|
||||
if "llm_billing_context" in locals():
|
||||
await _finalize_original_analysis_failure(
|
||||
task_set_id=task_set_id,
|
||||
attempt_no=attempt_no,
|
||||
token=token,
|
||||
billing_context=llm_billing_context,
|
||||
error=str(exc),
|
||||
)
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.ANALYSIS_FAILED.value,
|
||||
@@ -762,13 +743,12 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no
|
||||
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="拆镜复刻片段视频分析",
|
||||
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}),已终止片段视频分析任务"
|
||||
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
|
||||
if not pre_deduct_validation.can_execute:
|
||||
error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止片段视频分析任务"
|
||||
segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
|
||||
segment.analysis_claim_token = None
|
||||
segment.analysis_lease_until = None
|
||||
@@ -798,7 +778,7 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no
|
||||
|
||||
provider_succeeded = False
|
||||
analyzed = None
|
||||
log_provider_start(
|
||||
await log_provider_start(db,
|
||||
llm_billing_context,
|
||||
detail={"analysis_mode": "summary_only", "video_url": video_url},
|
||||
)
|
||||
@@ -811,9 +791,11 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no
|
||||
task_set_id=task_set_id,
|
||||
segment_id=segment_id,
|
||||
trace_id=f"shot-segment-analysis:{segment_id}:attempt:{attempt_no}",
|
||||
fixed_model_config_id=llm_billing_context.model_config_id,
|
||||
fixed_model_snapshot=llm_billing_context.model_parameters_snapshot,
|
||||
)
|
||||
provider_succeeded = True
|
||||
log_provider_success(llm_billing_context, usage=analyzed.usage)
|
||||
await log_provider_success(db, llm_billing_context, usage=analyzed.usage)
|
||||
try:
|
||||
await lease.ensure_owned()
|
||||
allow_business_write = True
|
||||
@@ -854,8 +836,11 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no
|
||||
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))
|
||||
if "llm_billing_context" in locals():
|
||||
if locals().get("provider_succeeded", False):
|
||||
await log_provider_failure(db, llm_billing_context, error=str(exc))
|
||||
else:
|
||||
provider_succeeded, _usage = await record_provider_exception(db, llm_billing_context, exc)
|
||||
if "llm_billing_context" in locals() and locals().get("provider_succeeded", False) and locals().get("analyzed") is not None:
|
||||
try:
|
||||
try:
|
||||
@@ -884,40 +869,18 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no
|
||||
)
|
||||
return
|
||||
except Exception as settlement_exc:
|
||||
manual_error = (
|
||||
"供应商已成功,但本地结果保存或积分结算失败;已终止自动恢复并保留冻结积分,"
|
||||
f"需人工对账。error={settlement_exc}"
|
||||
)
|
||||
await _mark_segment_provider_success_pending_manual(
|
||||
segment_id=segment_id,
|
||||
attempt_no=attempt_no,
|
||||
token=token,
|
||||
error_message=manual_error,
|
||||
)
|
||||
await log_provider_failure(db, llm_billing_context, error=str(settlement_exc))
|
||||
exc = settlement_exc
|
||||
elif "llm_billing_context" in locals():
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(ShotReplicateSegment)
|
||||
.where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None))
|
||||
.with_for_update()
|
||||
.limit(1)
|
||||
)
|
||||
segment = result.scalar_one_or_none()
|
||||
if (
|
||||
segment
|
||||
and int(segment.analysis_attempt_no or 1) == attempt_no
|
||||
and segment.analysis_claim_token == token
|
||||
and segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value
|
||||
):
|
||||
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 release_on_failure(db, llm_billing_context, error=str(exc))
|
||||
await db.commit()
|
||||
if "llm_billing_context" in locals():
|
||||
found_user_id, found_task_set_id = await _finalize_segment_analysis_failure(
|
||||
segment_id=segment_id,
|
||||
attempt_no=attempt_no,
|
||||
token=token,
|
||||
billing_context=llm_billing_context,
|
||||
error=str(exc),
|
||||
)
|
||||
user_id = user_id or found_user_id
|
||||
task_set_id = task_set_id or found_task_set_id
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_FAILED.value,
|
||||
@@ -984,20 +947,17 @@ async def _mark_auto_segment_analysis_dispatch_failed(
|
||||
segment.analysis_started_at = None
|
||||
segment.analysis_lease_until = None
|
||||
segment.analysis_error_message = error_message
|
||||
await release_on_failure(
|
||||
db,
|
||||
build_segment_analysis_billing_context(segment),
|
||||
error=error_message,
|
||||
)
|
||||
billing_context = build_segment_analysis_billing_context(segment)
|
||||
await db.commit()
|
||||
|
||||
await finalize_llm_business_failure(billing_context, error=error_message)
|
||||
log_module_error(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
project_id=task_set_id,
|
||||
step_id=segment_id,
|
||||
user_id=user_id,
|
||||
message="自定义切片分析任务投递失败,已标记分析失败并释放冻结积分",
|
||||
message="自定义切片分析任务投递失败,已标记分析失败并退回固定预扣积分",
|
||||
detail={
|
||||
"segment_id": segment_id,
|
||||
"task_set_id": task_set_id,
|
||||
@@ -1256,7 +1216,7 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user