积分冻结释放

This commit is contained in:
2026-07-24 09:18:05 +08:00
parent 920d884e92
commit 68e902b4a4
38 changed files with 4743 additions and 391 deletions
@@ -11,6 +11,12 @@ from app.services.module_async_recovery_service import (
register_module_step_task,
)
from app.services.module_generation_log_service import log_module_error, log_module_event_file
from app.services.llm_billing import (
LlmBillingContext,
log_celery_dispatch_failure,
log_celery_dispatch_start,
log_celery_dispatch_success,
)
from app.services.module_generation_v2.config import VIDEO_PROMPT_OPTIMIZE, ModuleGenerationV2Config
from app.tasks.celery_app import celery_app
from app.tasks.module_generation_v2_tasks import start_video_prompt_optimize_v2
@@ -38,12 +44,14 @@ async def dispatch_video_prompt_v2(
config: ModuleGenerationV2Config,
project_id: str,
step_id: str,
billing_context: LlmBillingContext,
) -> VideoPromptDispatchResult:
"""注册并投递 V2 视频提词任务。
Redis 注册成功但 Celery 直投失败时,由周期恢复任务补投;Celery 成功但
Redis 注册失败时任务仍可正常执行。只有两个通道都失败时由 API 补偿落库为失败。
"""
log_celery_dispatch_start(billing_context)
registry_error: Exception | None = None
try:
await register_module_step_task(
@@ -91,6 +99,7 @@ async def dispatch_video_prompt_v2(
celery_error=str(celery_error) if celery_error else None,
)
if result.celery_success:
log_celery_dispatch_success(billing_context)
log_module_event_file(
module=config.module,
event_type=ModuleEventTypeEnum.V2_VIDEO_PROMPT_DISPATCHED.value,
@@ -102,4 +111,9 @@ async def dispatch_video_prompt_v2(
"redis_registry_available": result.registry_success,
},
)
if not result.celery_success:
log_celery_dispatch_failure(
billing_context,
error=result.celery_error or "Celery direct dispatch failed; waiting for registry recovery",
)
return result
@@ -17,7 +17,9 @@ from app.enums.common import (
ModuleProjectStatusEnum,
ModuleStepStatusEnum,
)
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType
from app.enums.generation_task import ChatGenerationTaskStatus
from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.shot_replicate import (
ShotSegmentReplicateStatusEnum,
ShotSplitStatusEnum,
@@ -42,12 +44,21 @@ from app.services.generation.ai.engine_service import (
get_video_engine,
parse_json_list,
)
from app.services.generation.billing_service import charge_module_prompt_usage
from app.services.generation.pipeline.db_lock_service import (
DatabaseRowLockBusy,
execute_with_lock_timeout,
)
from app.services.generation.task_factory_service import create_chat_generation_task_for_module
from app.services.llm_billing import (
LlmBillingContext,
ensure_hold_exists,
log_provider_failure,
log_provider_start,
log_provider_success,
release_on_failure,
settle_success,
start_hold,
)
from app.services.hot_opening_video_prompt_service import (
build_final_video_prompt,
optimize_hot_opening_video_prompt,
@@ -331,6 +342,39 @@ async def _create_material_and_prompt_steps(
return material_step, prompt_step
def build_v2_video_prompt_billing_context(
*,
user_id: str,
project_id: str,
step_id: str,
step_version: int,
module: str,
display_name: str,
) -> LlmBillingContext:
return LlmBillingContext(
user_id=str(user_id),
owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value,
owner_id=str(step_id),
attempt_no=int(step_version or 1),
charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value,
billing_scene=(
CreditRecordBillingScene.HOT_OPENING_VIDEO_PROMPT_OPTIMIZE.value
if module == "hot_opening_replicate"
else CreditRecordBillingScene.SHOT_VIDEO_PROMPT_OPTIMIZE.value
),
source_module=str(module),
source_project_id=str(project_id),
source_step_id=str(step_id),
source_step_code=VIDEO_PROMPT_OPTIMIZE,
related_id=str(step_id),
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
description_prefix=f"{display_name}视频提词优化",
trace_id=f"module-v2-video-prompt:{step_id}",
)
async def create_hot_opening_project_v2(
db: AsyncSession,
*,
@@ -389,6 +433,17 @@ async def create_hot_opening_project_v2(
video_config=video_config,
target_platform=req.target_platform or "抖音",
)
await start_hold(
db,
build_v2_video_prompt_billing_context(
user_id=str(current_user.id),
project_id=str(project.id),
step_id=str(prompt_step.id),
step_version=int(prompt_step.version or 1),
module=HOT_OPENING_V2.module,
display_name=HOT_OPENING_V2.display_name,
),
)
await bind_upload_resources(
db,
user_id=current_user.id,
@@ -545,6 +600,17 @@ async def create_shot_replicate_project_v2(
urls=[req.material_image_url],
allow_common_migrate=True,
)
await start_hold(
db,
build_v2_video_prompt_billing_context(
user_id=str(current_user.id),
project_id=str(project.id),
step_id=str(prompt_step.id),
step_version=int(prompt_step.version or 1),
module=SHOT_REPLICATE_V2.module,
display_name=SHOT_REPLICATE_V2.display_name,
),
)
segment.module_project_id = project.id
segment.replicate_status = ShotSegmentReplicateStatusEnum.PROJECT_CREATED.value
await log_v2_event(
@@ -658,6 +724,17 @@ async def rebuild_video_prompt_step_v2(
project.final_video_cover_url = None
project.completed_at = None
project.error_message = None
await start_hold(
db,
build_v2_video_prompt_billing_context(
user_id=str(project.user_id),
project_id=str(project.id),
step_id=str(step.id),
step_version=int(step.version or 1),
module=config.module,
display_name=config.display_name,
),
)
await log_v2_event(
db,
project=project,
@@ -722,6 +799,18 @@ async def mark_video_prompt_dispatch_failed_v2(
message=error_message,
detail={"dispatch_compensated": True},
)
await release_on_failure(
db,
build_v2_video_prompt_billing_context(
user_id=str(project.user_id),
project_id=str(project.id),
step_id=str(step.id),
step_version=int(step.version or 1),
module=config.module,
display_name=config.display_name,
),
error=error_message,
)
await db.commit()
@@ -783,8 +872,41 @@ async def run_video_prompt_optimize_v2(
if not project_snapshot["material_video_url"]:
raise RuntimeError("V2 素材步骤缺少参考视频")
schema_config_snapshot = await get_runtime_schema_snapshot(db)
llm_billing_context = LlmBillingContext(
user_id=project_snapshot["user_id"],
owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value,
owner_id=project_snapshot["step_id"],
attempt_no=expected_version,
charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value,
billing_scene=(
CreditRecordBillingScene.HOT_OPENING_VIDEO_PROMPT_OPTIMIZE.value
if project_snapshot["module"] == "hot_opening_replicate"
else CreditRecordBillingScene.SHOT_VIDEO_PROMPT_OPTIMIZE.value
),
source_module=project_snapshot["module"],
source_project_id=project_snapshot["project_id"],
source_step_id=project_snapshot["step_id"],
source_step_code=VIDEO_PROMPT_OPTIMIZE,
related_id=project_snapshot["step_id"],
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
description_prefix=f"{config.display_name}视频提词优化",
trace_id=f"module-v2-video-prompt:{project_snapshot['step_id']}",
)
hold_validation = await ensure_hold_exists(db, llm_billing_context)
if not hold_validation.can_execute:
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务"
step.completed_at = utc_now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = step.error_message
await log_v2_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value, message=step.error_message)
await db.commit()
return step
await db.commit()
provider_succeeded = False
usage: dict[str, Any] = {}
log_provider_start(llm_billing_context, detail={"prompt_type": "video", "flow_version": "v2"})
prompt_schema, final_prompt, usage = await optimize_hot_opening_video_prompt(
db,
user_id=project_snapshot["user_id"],
@@ -800,6 +922,8 @@ async def run_video_prompt_optimize_v2(
project_id=project_snapshot["project_id"],
step_id=project_snapshot["step_id"],
)
provider_succeeded = True
log_provider_success(llm_billing_context, usage=usage)
if execution_guard is not None:
await execution_guard()
@@ -821,10 +945,24 @@ async def run_video_prompt_optimize_v2(
row = locked.first()
if not row:
await db.rollback()
# Provider 已成功,即使业务对象被异常移除,也必须按真实 usage 完成幂等结算。
await settle_success(
db,
llm_billing_context,
usage=usage,
description=f"{config.display_name}-视频提词优化(业务对象失效结算)",
)
await db.commit()
return None
project, step = row
if int(step.version) != expected_version or step.input_json != expected_input or step.status != ModuleStepStatusEnum.PROCESSING.value:
await db.rollback()
await settle_success(
db,
llm_billing_context,
usage=usage,
description=f"{config.display_name}-视频提词优化(失效结果结算)",
)
await db.commit()
log_module_event_file(
module=project_snapshot["module"],
event_type=ModuleEventTypeEnum.STALE_STEP_RESULT_DISCARDED.value,
@@ -835,13 +973,11 @@ async def run_video_prompt_optimize_v2(
detail={"expected_version": expected_version},
)
return None
billing = await charge_module_prompt_usage(
billing = await settle_success(
db,
user_id=project.user_id,
step_id=step.id,
llm_billing_context,
usage=usage,
description=f"{config.display_name}-视频提词优化",
attempt_no=1,
)
output_payload = {
"prompt_schema": prompt_schema,
@@ -861,7 +997,7 @@ async def run_video_prompt_optimize_v2(
payload=output_payload,
usage={
**dict(usage or {}),
"text_credits_cost": round(billing.total_charged, 2),
"text_credits_cost": billing.get_amount(CreditRecordChargeKind.TEXT_PROMPT.value),
},
schema_version=config.io_schema_version,
),
@@ -884,10 +1020,31 @@ async def run_video_prompt_optimize_v2(
return step
except DatabaseRowLockBusy:
await db.rollback()
if locals().get("provider_succeeded", False):
await settle_success(
db,
llm_billing_context,
usage=locals().get("usage") or {},
description=f"{config.display_name}-视频提词优化(行锁失败结算)",
)
await db.commit()
return None
raise
except Exception as exc:
await db.rollback()
if "llm_billing_context" in locals() and not locals().get("provider_succeeded", False):
log_provider_failure(llm_billing_context, error=str(exc))
try:
if "llm_billing_context" in locals():
if locals().get("provider_succeeded", False):
await settle_success(
db,
llm_billing_context,
usage=locals().get("usage") or {},
description=f"{config.display_name}-视频提词优化(本地失败结算)",
)
else:
await release_on_failure(db, llm_billing_context, error=str(exc))
if execution_guard is not None:
await execution_guard()
result = await execute_with_lock_timeout(
@@ -913,7 +1070,9 @@ async def run_video_prompt_optimize_v2(
step.completed_at = utc_now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = str(exc)
await db.commit()
# provider 已成功时 settle_success 已在当前事务写入 RELEASE/CHARGE
# 即使业务步骤已不存在或已不是 processing,也必须提交账务结算。
await db.commit()
log_module_error(
module=row[0].module if row else "module_generation_v2",
event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value,
@@ -930,9 +1089,11 @@ async def run_video_prompt_optimize_v2(
project_id=project_id,
step_id=step_id,
message="V2 视频提词失败状态落库失败",
detail={"origin_error": str(exc)},
detail={"origin_error": str(exc), "provider_succeeded": locals().get("provider_succeeded", False)},
exc=mark_exc,
)
if locals().get("provider_succeeded", False):
raise
return None