修复冻结积分BUG | 拆镜状态异常BUG

This commit is contained in:
2026-07-24 14:00:37 +08:00
parent 357657d7cd
commit 4685b475af
22 changed files with 2159 additions and 733 deletions
+553 -180
View File
@@ -33,7 +33,10 @@ from app.services.redis_registry_service import (
)
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_replicate_taskset_service import (
build_segment_analysis_billing_context,
refresh_task_set_split_summary,
)
from app.services.shot_video_analysis_service import analyze_video_for_shot_split
from app.services.llm_billing import (
LlmBillingContext,
@@ -126,9 +129,237 @@ def _analysis_lock_key(owner_type: str, owner_id: str, attempt_no: int) -> str:
return f"{settings.SHOT_ANALYSIS_LOCK_KEY_PREFIX}:{owner_type}:{owner_id}:attempt:{attempt_no}"
async def _run_analyze_original_video(task_set_id: str) -> None:
def _log_stale_analysis_attempt(
*,
owner_type: str,
owner_id: str,
expected_attempt_no: int | None,
actual_attempt_no: int | None,
analysis_status: str | None,
task_set_id: str | None = None,
user_id: str | None = None,
reason: str,
) -> None:
project_id = task_set_id or (owner_id if owner_type == "shot_task_set" else None)
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.ANALYSIS_STALE_ATTEMPT_SKIPPED.value,
project_id=project_id,
step_id=owner_id if owner_type == "shot_segment" else None,
user_id=user_id,
message="拆镜分析任务 attempt 已失效,跳过执行",
detail={
"owner_type": owner_type,
"owner_id": owner_id,
"expected_attempt_no": expected_attempt_no,
"actual_attempt_no": actual_attempt_no,
"analysis_status": analysis_status,
"reason": reason,
},
event_status="skipped",
)
async def _persist_original_analysis_result(
*,
task_set_id: str,
attempt_no: int,
token: str,
video_url: str | None,
analyzed: Any,
billing_context: LlmBillingContext,
allow_business_write: bool,
) -> str:
"""持久化原视频分析结果并完成账务;供应商成功后禁止再次调用模型。"""
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()
is_current = bool(
allow_business_write
and task_set
and int(task_set.analysis_attempt_no or 1) == attempt_no
and task_set.analysis_claim_token == token
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(
db,
billing_context,
usage=analyzed.usage,
description=description,
)
await db.commit()
return outcome
async def _persist_segment_analysis_result(
*,
segment_id: str,
attempt_no: int,
token: str,
video_url: str | None,
analyzed: Any,
billing_context: LlmBillingContext,
allow_business_write: bool,
) -> str:
"""持久化自定义切片分析结果并完成账务;供应商成功后禁止再次调用模型。"""
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()
is_current = bool(
allow_business_write
and segment
and int(segment.analysis_attempt_no or 1) == attempt_no
and segment.analysis_claim_token == token
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(
db,
billing_context,
usage=analyzed.usage,
description=description,
)
await db.commit()
return outcome
async def _mark_original_provider_success_pending_manual(
*,
task_set_id: str,
attempt_no: int,
token: str,
error_message: str,
) -> bool:
"""供应商已成功但本地结算失败:终止自动恢复,保留 HOLD 等待人工对账。"""
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 not (
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
await db.commit()
return True
async def _mark_segment_provider_success_pending_manual(
*,
segment_id: str,
attempt_no: int,
token: str,
error_message: str,
) -> bool:
"""供应商已成功但本地结算失败:终止自动恢复,保留 HOLD 等待人工对账。"""
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 not (
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
await db.commit()
return True
async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int | None) -> None:
token = uuid.uuid4().hex
attempt_no = 1
if expected_attempt_no is None:
_log_stale_analysis_attempt(
owner_type="shot_task_set",
owner_id=task_set_id,
expected_attempt_no=None,
actual_attempt_no=None,
analysis_status=None,
reason="missing_expected_attempt_no",
)
return
attempt_no = max(1, int(expected_attempt_no))
async with async_session() as db:
row = await db.execute(
@@ -137,9 +368,25 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
.limit(1)
)
initial = row.scalar_one_or_none()
if not initial or initial.analysis_status == ShotAnalysisStatusEnum.COMPLETED.value:
if not initial:
return
actual_attempt_no = int(initial.analysis_attempt_no or 1)
if (
actual_attempt_no != attempt_no
or initial.analysis_status
not in (ShotAnalysisStatusEnum.PENDING.value, ShotAnalysisStatusEnum.PROCESSING.value)
):
_log_stale_analysis_attempt(
owner_type="shot_task_set",
owner_id=task_set_id,
expected_attempt_no=attempt_no,
actual_attempt_no=actual_attempt_no,
analysis_status=initial.analysis_status,
user_id=str(initial.user_id),
reason="attempt_or_status_mismatch_before_lock",
)
await db.rollback()
return
attempt_no = int(initial.analysis_attempt_no or 1)
await db.rollback()
lease = await CeleryRuntimeLease.acquire(
@@ -174,7 +421,24 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
.limit(1)
)
task_set = result.scalar_one_or_none()
if not task_set or task_set.analysis_status == ShotAnalysisStatusEnum.COMPLETED.value:
if not task_set:
await db.rollback()
return
actual_attempt_no = int(task_set.analysis_attempt_no or 1)
if (
actual_attempt_no != attempt_no
or task_set.analysis_status
not in (ShotAnalysisStatusEnum.PENDING.value, ShotAnalysisStatusEnum.PROCESSING.value)
):
_log_stale_analysis_attempt(
owner_type="shot_task_set",
owner_id=task_set_id,
expected_attempt_no=attempt_no,
actual_attempt_no=actual_attempt_no,
analysis_status=task_set.analysis_status,
user_id=str(task_set.user_id),
reason="attempt_or_status_mismatch_after_lock",
)
await db.rollback()
return
current_lease = task_set.analysis_lease_until
@@ -186,9 +450,6 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
):
await db.rollback()
return
if int(task_set.analysis_attempt_no or 1) != attempt_no:
await db.rollback()
return
task_set_user_id = str(task_set.user_id)
video_url = str(task_set.video_url)
task_set.status = ShotTaskSetStatusEnum.ANALYZING.value
@@ -263,52 +524,25 @@ 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)
provider_succeeded = True
log_provider_success(llm_billing_context, usage=analyzed.usage)
try:
await lease.ensure_owned()
result = await call_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 (
not task_set
or int(task_set.analysis_attempt_no or 1) != attempt_no
or task_set.analysis_claim_token != token
or str(task_set.video_url) != str(video_url)
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 "")
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 settle_success(
call_db,
llm_billing_context,
usage=analyzed.usage,
description="拆镜复刻-原视频分析",
)
await call_db.commit()
allow_business_write = True
except RedisExecutionLockError:
# Provider 已成功后不能让 Celery retry 再次调用模型;失去执行权时仅结算。
allow_business_write = False
persist_outcome = await _persist_original_analysis_result(
task_set_id=task_set_id,
attempt_no=attempt_no,
token=token,
video_url=video_url,
analyzed=analyzed,
billing_context=llm_billing_context,
allow_business_write=allow_business_write,
)
if persist_outcome != "completed":
return
log_module_prompt_event(
event_type=ShotReplicateLogEventEnum.ANALYSIS_SUCCESS.value,
@@ -334,40 +568,67 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
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)
.where(ShotReplicateTaskSet.id == task_set_id, ShotReplicateTaskSet.deleted_at.is_(None))
.with_for_update()
.limit(1)
)
task_set = result.scalar_one_or_none()
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)
# 业务对象是否仍有效,不影响本 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="拆镜复刻-原视频分析(本地失败结算)",
if "llm_billing_context" in locals() and locals().get("provider_succeeded", False) and locals().get("analyzed") is not None:
try:
try:
await lease.ensure_owned()
allow_business_write = True
except RedisExecutionLockError:
allow_business_write = False
persist_outcome = await _persist_original_analysis_result(
task_set_id=task_set_id,
attempt_no=attempt_no,
token=token,
video_url=video_url,
analyzed=analyzed,
billing_context=llm_billing_context,
allow_business_write=allow_business_write,
)
if persist_outcome == "completed":
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.ANALYSIS_SUCCESS.value,
project_id=task_set_id,
user_id=task_set_user_id,
message="原视频分析本地异常后幂等恢复成功",
detail={"task_set_id": task_set_id, "analysis_attempt_no": attempt_no},
)
else:
await release_on_failure(db, llm_billing_context, error=str(exc))
await db.commit()
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,
)
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()
log_module_error(
module=MODULE,
event_type=ShotReplicateLogEventEnum.ANALYSIS_FAILED.value,
@@ -381,9 +642,19 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
await lease.close()
async def _run_analyze_custom_segment_video(segment_id: str) -> None:
async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no: int | None) -> None:
token = uuid.uuid4().hex
attempt_no = 1
if expected_attempt_no is None:
_log_stale_analysis_attempt(
owner_type="shot_segment",
owner_id=segment_id,
expected_attempt_no=None,
actual_attempt_no=None,
analysis_status=None,
reason="missing_expected_attempt_no",
)
return
attempt_no = max(1, int(expected_attempt_no))
async with async_session() as db:
row = await db.execute(
select(ShotReplicateSegment)
@@ -391,9 +662,26 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None:
.limit(1)
)
initial = row.scalar_one_or_none()
if not initial or not initial.segment_video_url or initial.analysis_status == ShotSegmentAnalysisStatusEnum.COMPLETED.value:
if not initial or not initial.segment_video_url:
return
actual_attempt_no = int(initial.analysis_attempt_no or 1)
if (
actual_attempt_no != attempt_no
or initial.analysis_status
not in (ShotSegmentAnalysisStatusEnum.PENDING.value, ShotSegmentAnalysisStatusEnum.PROCESSING.value)
):
_log_stale_analysis_attempt(
owner_type="shot_segment",
owner_id=segment_id,
expected_attempt_no=attempt_no,
actual_attempt_no=actual_attempt_no,
analysis_status=initial.analysis_status,
task_set_id=str(initial.task_set_id),
user_id=str(initial.user_id),
reason="attempt_or_status_mismatch_before_lock",
)
await db.rollback()
return
attempt_no = int(initial.analysis_attempt_no or 1)
await db.rollback()
lease = await CeleryRuntimeLease.acquire(
@@ -429,16 +717,31 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None:
.limit(1)
)
segment = result.scalar_one_or_none()
if not segment or not segment.segment_video_url or segment.analysis_status == ShotSegmentAnalysisStatusEnum.COMPLETED.value:
if not segment or not segment.segment_video_url:
await db.rollback()
return
actual_attempt_no = int(segment.analysis_attempt_no or 1)
if (
actual_attempt_no != attempt_no
or segment.analysis_status
not in (ShotSegmentAnalysisStatusEnum.PENDING.value, ShotSegmentAnalysisStatusEnum.PROCESSING.value)
):
_log_stale_analysis_attempt(
owner_type="shot_segment",
owner_id=segment_id,
expected_attempt_no=attempt_no,
actual_attempt_no=actual_attempt_no,
analysis_status=segment.analysis_status,
task_set_id=str(segment.task_set_id),
user_id=str(segment.user_id),
reason="attempt_or_status_mismatch_after_lock",
)
await db.rollback()
return
current_lease = segment.analysis_lease_until
if segment.analysis_claim_token and segment.analysis_claim_token != token and current_lease and current_lease > _now():
await db.rollback()
return
if int(segment.analysis_attempt_no or 1) != attempt_no:
await db.rollback()
return
user_id = str(segment.user_id)
task_set_id = str(segment.task_set_id)
video_url = str(segment.segment_video_url)
@@ -509,51 +812,24 @@ 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)
provider_succeeded = True
log_provider_success(llm_billing_context, usage=analyzed.usage)
try:
await lease.ensure_owned()
result = await call_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 (
not segment
or int(segment.analysis_attempt_no or 1) != attempt_no
or segment.analysis_claim_token != token
or str(segment.segment_video_url) != str(video_url)
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 "")
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 settle_success(
call_db,
llm_billing_context,
usage=analyzed.usage,
description="拆镜复刻-片段视频分析",
)
await call_db.commit()
allow_business_write = True
except RedisExecutionLockError:
allow_business_write = False
persist_outcome = await _persist_segment_analysis_result(
segment_id=segment_id,
attempt_no=attempt_no,
token=token,
video_url=video_url,
analyzed=analyzed,
billing_context=llm_billing_context,
allow_business_write=allow_business_write,
)
if persist_outcome != "completed":
return
log_module_prompt_event(
event_type=ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_SUCCESS.value,
@@ -580,38 +856,68 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None:
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)
.where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None))
.with_for_update()
.limit(1)
)
segment = result.scalar_one_or_none()
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)
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="拆镜复刻-片段视频分析(本地失败结算)",
if "llm_billing_context" in locals() and locals().get("provider_succeeded", False) and locals().get("analyzed") is not None:
try:
try:
await lease.ensure_owned()
allow_business_write = True
except RedisExecutionLockError:
allow_business_write = False
persist_outcome = await _persist_segment_analysis_result(
segment_id=segment_id,
attempt_no=attempt_no,
token=token,
video_url=video_url,
analyzed=analyzed,
billing_context=llm_billing_context,
allow_business_write=allow_business_write,
)
if persist_outcome == "completed":
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_SUCCESS.value,
project_id=task_set_id,
step_id=segment_id,
user_id=user_id,
message="切片视频分析本地异常后幂等恢复成功",
detail={"segment_id": segment_id, "analysis_attempt_no": attempt_no},
)
else:
await release_on_failure(db, llm_billing_context, error=str(exc))
await db.commit()
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,
)
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()
log_module_error(
module=MODULE,
event_type=ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_FAILED.value,
@@ -643,6 +949,65 @@ async def _renew_split_lease(segment_id: str, attempt_no: int, token: str) -> bo
return bool(result.rowcount == 1)
async def _mark_auto_segment_analysis_dispatch_failed(
*,
segment_id: str,
expected_attempt_no: int,
error_message: str,
) -> bool:
"""切片成功后的自动分析投递失败补偿;不回滚或清理已完成的切片文件。"""
task_set_id: str | None = None
user_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),
)
.with_for_update()
.limit(1)
)
segment = result.scalar_one_or_none()
if (
not segment
or int(segment.analysis_attempt_no or 1) != int(expected_attempt_no)
or segment.analysis_status != ShotSegmentAnalysisStatusEnum.PENDING.value
):
await db.rollback()
return False
task_set_id = str(segment.task_set_id)
user_id = str(segment.user_id)
segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
segment.analysis_claim_token = None
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,
)
await db.commit()
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="自定义切片分析任务投递失败,已标记分析失败并释放冻结积分",
detail={
"segment_id": segment_id,
"task_set_id": task_set_id,
"analysis_attempt_no": expected_attempt_no,
},
exc=RuntimeError(error_message),
)
return True
async def _run_split_one_segment(segment_id: str) -> None:
async with async_session() as db:
row = await db.execute(
@@ -826,6 +1191,7 @@ async def _run_split_one_segment(segment_id: str) -> None:
source_mode = str(segment.source_mode)
final_task_set_id = str(segment.task_set_id)
final_user_id = str(segment.user_id)
analysis_attempt_no = max(1, int(segment.analysis_attempt_no or 1))
await refresh_task_set_split_summary(db, segment.task_set_id)
await db.commit()
@@ -847,12 +1213,19 @@ async def _run_split_one_segment(segment_id: str) -> None:
)
if source_mode == ShotSegmentSourceModeEnum.CUSTOM.value and celery_app:
analyze_custom_segment_video.apply_async(
args=[segment_id],
queue=ANALYSIS_QUEUE,
countdown=0,
task_id=f"shot-analysis:segment:{segment_id}:attempt:1",
)
try:
analyze_custom_segment_video.apply_async(
args=[segment_id, analysis_attempt_no],
queue=ANALYSIS_QUEUE,
countdown=0,
task_id=f"shot-analysis:segment:{segment_id}:attempt:{analysis_attempt_no}",
)
except Exception as dispatch_exc:
await _mark_auto_segment_analysis_dispatch_failed(
segment_id=segment_id,
expected_attempt_no=analysis_attempt_no,
error_message=f"自定义切片分析任务投递失败: {dispatch_exc}",
)
except RedisExecutionLockError:
cleanup_split_result(split_result)
raise
@@ -980,9 +1353,9 @@ if celery_app:
time_limit=settings.SHOT_ANALYSIS_TIME_LIMIT_SECONDS,
ignore_result=True,
)
def analyze_original_video(self, task_set_id: str) -> None:
def analyze_original_video(self, task_set_id: str, expected_attempt_no: int | None = None) -> None:
try:
return run_async(_run_analyze_original_video(task_set_id))
return run_async(_run_analyze_original_video(task_set_id, expected_attempt_no))
except RedisExecutionLockError as exc:
raise self.retry(exc=exc, countdown=60)
@@ -1004,9 +1377,9 @@ if celery_app:
time_limit=settings.SHOT_ANALYSIS_TIME_LIMIT_SECONDS,
ignore_result=True,
)
def analyze_custom_segment_video(self, segment_id: str) -> None:
def analyze_custom_segment_video(self, segment_id: str, expected_attempt_no: int | None = None) -> None:
try:
return run_async(_run_analyze_custom_segment_video(segment_id))
return run_async(_run_analyze_custom_segment_video(segment_id, expected_attempt_no))
except RedisExecutionLockError as exc:
raise self.retry(exc=exc, countdown=60)