修复冻结积分BUG | 拆镜状态异常BUG
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user