积分冻结释放

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,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType
from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.shot_replicate import ShotReplicateGenerationModeEnum, ShotReplicateStepCodeEnum, ModuleCodeEnum
from app.models.chat_generation_task import ChatGenerationTask
from app.models.module_generation_project import ModuleGenerationProject
@@ -41,7 +43,6 @@ 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.refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.generation.pipeline.db_lock_service import (
DatabaseRowLockBusy,
@@ -56,6 +57,16 @@ from app.services.hot_opening_video_prompt_service import (
)
from app.services.module_generation_log_service import log_module_error, log_module_event_file, log_module_prompt_event
from app.services.llm import optimize_prompt
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.module_generation_flow_base_service import (
assert_project_has_no_active_chat_tasks as _base_assert_project_has_no_active_chat_tasks,
chat_tasks_by_id as _base_chat_tasks_by_id,
@@ -820,6 +831,25 @@ async def submit_image_prompt_optimize(
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
project.error_message = None
await start_hold(
db,
LlmBillingContext(
user_id=str(project.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.SHOT_IMAGE_PROMPT_OPTIMIZE.value,
source_module=MODULE,
source_project_id=str(project.id),
source_step_id=str(step.id),
source_step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
related_id=str(step.id),
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value,
description_prefix="拆镜复刻图片AI提词优化",
trace_id=f"llm-submit-hold:{step.id}",
),
)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_SUBMITTED.value, message="图片 AI 提词任务已提交")
return project, step
@@ -902,8 +932,37 @@ async def run_image_prompt_optimize(
module_value = str(project.module)
expected_step_version = int(step.version or 1)
expected_input_json = json.dumps(step.input_json, ensure_ascii=False, sort_keys=True, default=str)
llm_billing_context = LlmBillingContext(
user_id=user_id_value,
owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value,
owner_id=step_id_value,
attempt_no=expected_step_version,
charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value,
billing_scene=CreditRecordBillingScene.SHOT_IMAGE_PROMPT_OPTIMIZE.value,
source_module=module_value,
source_project_id=project_id_value,
source_step_id=step_id_value,
source_step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
related_id=step_id_value,
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value,
description_prefix="拆镜复刻图片AI提词优化",
trace_id=f"shot-image-prompt:{step_id_value}",
)
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 = _now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = step.error_message
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.CHAT_TASK_FAILED.value, message=step.error_message)
await db.commit()
return step
await db.commit()
provider_succeeded = False
token_usage: dict[str, Any] = {}
log_provider_start(llm_billing_context, detail={"prompt_type": "image"})
try:
request_log = {"original_prompt": prompt_text, "references": references, "gen_type": "image"}
log_module_prompt_event(
@@ -929,6 +988,8 @@ async def run_image_prompt_optimize(
log_owner_id=step_id_value,
generation_attempt_no=expected_step_version,
)
provider_succeeded = True
log_provider_success(llm_billing_context, usage=token_usage)
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -942,19 +1003,25 @@ async def run_image_prompt_optimize(
expected_version=expected_step_version,
expected_input_json=expected_input_json,
):
await db.rollback()
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-图片AI提词优化(失效结果结算)",
)
await db.commit()
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=token_usage,
description="拆镜复刻-图片AI提词优化",
)
actual_billing_item = next((item for item in billing.items if item.charge_key == CreditRecordChargeKind.TEXT_PROMPT.value and item.charged), None)
usage = dict(token_usage or {})
usage.update({
"text_credits_cost": (billing.items[0].amount if billing.items else billing.total_charged),
"credit_biz_key": billing.items[0].biz_key if billing.items else None,
"text_credits_cost": billing.get_amount(CreditRecordChargeKind.TEXT_PROMPT.value),
"credit_biz_key": actual_billing_item.biz_key if actual_billing_item else None,
})
step.status = ModuleStepStatusEnum.COMPLETED.value
step.completed_at = _now()
@@ -991,9 +1058,22 @@ async def run_image_prompt_optimize(
await db.commit()
except DatabaseRowLockBusy:
await db.rollback()
if provider_succeeded:
# Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-图片AI提词优化(行锁失败结算)",
)
await db.commit()
return None
# Provider 尚未成功才允许同一 attempt 做系统自动重试。
raise
except Exception as exc:
await db.rollback()
if not provider_succeeded:
log_provider_failure(llm_billing_context, error=str(exc))
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1008,6 +1088,16 @@ async def run_image_prompt_optimize(
expected_input_json=expected_input_json,
):
await db.rollback()
if provider_succeeded:
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-图片AI提词优化(异常失效结算)",
)
else:
await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分")
await db.commit()
return None
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = str(exc)
@@ -1026,6 +1116,15 @@ async def run_image_prompt_optimize(
)
_log_project_error(project=project, step=step, event_type="IMAGE_PROMPT_FAILED", message=project.error_message, exc=exc)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_FAILED.value, message=project.error_message)
if provider_succeeded:
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-图片AI提词优化(本地失败结算)",
)
else:
await release_on_failure(db, llm_billing_context, error=str(exc))
await db.commit()
return step
@@ -1200,6 +1299,25 @@ async def submit_video_prompt_optimize(
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value
project.error_message = None
await start_hold(
db,
LlmBillingContext(
user_id=str(project.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.SHOT_VIDEO_PROMPT_OPTIMIZE.value,
source_module=MODULE,
source_project_id=str(project.id),
source_step_id=str(step.id),
source_step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
related_id=str(step.id),
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
description_prefix="拆镜复刻视频AI提词优化",
trace_id=f"llm-submit-hold:{step.id}",
),
)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_SUBMITTED.value, message="视频 AI 提词任务已提交")
return project, step
@@ -1280,8 +1398,37 @@ async def run_video_prompt_optimize(
module_value = str(project.module)
expected_step_version = int(step.version or 1)
expected_input_json = json.dumps(step.input_json, ensure_ascii=False, sort_keys=True, default=str)
llm_billing_context = LlmBillingContext(
user_id=user_id_value,
owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value,
owner_id=step_id_value,
attempt_no=expected_step_version,
charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value,
billing_scene=CreditRecordBillingScene.SHOT_VIDEO_PROMPT_OPTIMIZE.value,
source_module=module_value,
source_project_id=project_id_value,
source_step_id=step_id_value,
source_step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
related_id=step_id_value,
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
description_prefix="拆镜复刻视频AI提词优化",
trace_id=f"shot-video-prompt:{step_id_value}",
)
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 = _now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = step.error_message
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.CHAT_TASK_FAILED.value, message=step.error_message)
await db.commit()
return step
await db.commit()
provider_succeeded = False
token_usage: dict[str, Any] = {}
log_provider_start(llm_billing_context, detail={"prompt_type": "video"})
try:
request_log = {
"source_project_name": material.get("source_project_name") or "",
@@ -1319,6 +1466,8 @@ async def run_video_prompt_optimize(
step_id=step_id_value,
trace_id=f"shot-video-prompt:{step_id_value}",
)
provider_succeeded = True
log_provider_success(llm_billing_context, usage=token_usage)
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1332,19 +1481,25 @@ async def run_video_prompt_optimize(
expected_version=expected_step_version,
expected_input_json=expected_input_json,
):
await db.rollback()
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-视频AI提词优化(失效结果结算)",
)
await db.commit()
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=token_usage,
description="拆镜复刻-视频AI提词优化",
)
actual_billing_item = next((item for item in billing.items if item.charge_key == CreditRecordChargeKind.TEXT_PROMPT.value and item.charged), None)
usage = dict(token_usage or {})
usage.update({
"text_credits_cost": (billing.items[0].amount if billing.items else billing.total_charged),
"credit_biz_key": billing.items[0].biz_key if billing.items else None,
"text_credits_cost": billing.get_amount(CreditRecordChargeKind.TEXT_PROMPT.value),
"credit_biz_key": actual_billing_item.biz_key if actual_billing_item else None,
})
step.status = ModuleStepStatusEnum.COMPLETED.value
step.completed_at = _now()
@@ -1384,9 +1539,22 @@ async def run_video_prompt_optimize(
await db.commit()
except DatabaseRowLockBusy:
await db.rollback()
if provider_succeeded:
# Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-视频AI提词优化(行锁失败结算)",
)
await db.commit()
return None
# Provider 尚未成功才允许同一 attempt 做系统自动重试。
raise
except Exception as exc:
await db.rollback()
if not provider_succeeded:
log_provider_failure(llm_billing_context, error=str(exc))
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1401,6 +1569,16 @@ async def run_video_prompt_optimize(
expected_input_json=expected_input_json,
):
await db.rollback()
if provider_succeeded:
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-视频AI提词优化(异常失效结算)",
)
else:
await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分")
await db.commit()
return None
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = str(exc)
@@ -1419,6 +1597,15 @@ async def run_video_prompt_optimize(
)
_log_project_error(project=project, step=step, event_type="VIDEO_PROMPT_FAILED", message=project.error_message, exc=exc)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value, message=project.error_message)
if provider_succeeded:
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-视频AI提词优化(本地失败结算)",
)
else:
await release_on_failure(db, llm_billing_context, error=str(exc))
await db.commit()
return step
@@ -1680,7 +1867,20 @@ async def _assert_project_has_no_active_chat_tasks_for_delete(
*,
project: ModuleGenerationProject,
) -> None:
"""用户主动删除项目/切片时不退款;如仍有异步生成任务进行中,直接拦截。"""
"""用户主动删除项目/切片时不退款;如仍有异步任务进行中,直接拦截。"""
processing_result = await db.execute(
select(func.count())
.select_from(ModuleGenerationStep)
.where(
ModuleGenerationStep.project_id == project.id,
ModuleGenerationStep.module == MODULE,
ModuleGenerationStep.deleted_at.is_(None),
ModuleGenerationStep.is_current == True,
ModuleGenerationStep.status == ModuleStepStatusEnum.PROCESSING.value,
)
)
if int(processing_result.scalar() or 0) > 0:
raise HTTPException(status_code=409, detail="当前拆镜复刻项目仍有 AI 任务处理中,暂不能删除")
await _base_assert_project_has_no_active_chat_tasks(
db,
project=project,
@@ -1726,6 +1926,39 @@ async def mark_shot_replicate_step_dispatch_failed(
step.completed_at = _now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = error_message
if step.step_code in (ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value):
await release_on_failure(
db,
LlmBillingContext(
user_id=str(project.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.SHOT_IMAGE_PROMPT_OPTIMIZE.value
if step.step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
else CreditRecordBillingScene.SHOT_VIDEO_PROMPT_OPTIMIZE.value
),
source_module=MODULE,
source_project_id=str(project.id),
source_step_id=str(step.id),
source_step_code=str(step.step_code),
related_id=str(step.id),
hold_config_key=(
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
if step.step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
),
description_prefix=(
"拆镜复刻图片AI提词优化"
if step.step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
else "拆镜复刻视频AI提词优化"
),
trace_id=f"shot-replicate-dispatch-failed:{step.id}",
),
error=error_message,
)
log_module_error(
module=project.module,
event_type="CELERY_DISPATCH_FAILED",