542 lines
23 KiB
Python
542 lines
23 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, replace
|
|
from typing import Any, Iterable, Mapping
|
|
|
|
from sqlalchemy import and_, or_, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.enums.credit_record import (
|
|
CREDIT_RECORD_BILLING_SCENE_LABELS,
|
|
CreditRecordAction,
|
|
CreditRecordSubject,
|
|
)
|
|
from app.enums.llm_billing import (
|
|
LlmBillingDomain,
|
|
LlmBillingEvent,
|
|
LlmBillingExecutionStatus,
|
|
LlmBillingLedgerState,
|
|
)
|
|
from app.models.base import AsyncSessionLocal
|
|
from app.models.credit_record import CreditRecord
|
|
from app.models.llm_billing.call_attempt import LlmCallAttempt
|
|
from app.models.llm_billing.execution import LlmBillingExecution
|
|
from app.models.model_config import ModelConfig
|
|
from app.services.credit.locking import acquire_user_credit_lock
|
|
from app.services.credit.utils import to_float, utc_now
|
|
from app.services.credits import deduct_credits_result
|
|
from app.services.generation.billing_service import BillingItem, BillingSummary
|
|
from app.services.llm_billing.call_audit_service import (
|
|
create_call_attempt,
|
|
finish_call_failure,
|
|
finish_call_success,
|
|
)
|
|
from app.services.llm_billing.config import get_llm_billing_policy
|
|
from app.services.llm_billing.context import (
|
|
LlmBillingConfigurationError,
|
|
LlmBillingContext,
|
|
LlmBillingStateError,
|
|
LlmProviderPostprocessError,
|
|
LlmPreDeductResult,
|
|
LlmPreDeductValidation,
|
|
)
|
|
from app.services.operation_log_service import log_operation_event
|
|
from app.utils.id_gen import generate_id
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _ExecutionState:
|
|
execution: LlmBillingExecution | None
|
|
state: LlmBillingLedgerState
|
|
reason: str | None = None
|
|
|
|
|
|
def _scene_name(ctx: LlmBillingContext, policy_name: str | None = None) -> str:
|
|
return (
|
|
policy_name
|
|
or CREDIT_RECORD_BILLING_SCENE_LABELS.get(ctx.billing_scene or "")
|
|
or ctx.description_prefix
|
|
or ctx.billing_scene
|
|
or "LLM功能"
|
|
)
|
|
|
|
|
|
def _detail(ctx: LlmBillingContext, **extra: Any) -> dict[str, Any]:
|
|
return {
|
|
"scene_code": ctx.billing_scene,
|
|
"owner_type": ctx.owner_type,
|
|
"owner_id": ctx.owner_id,
|
|
"attempt_no": ctx.attempt_no,
|
|
"billing_execution_id": ctx.billing_execution_id,
|
|
"model_config_id": ctx.model_config_id,
|
|
"model_name": ctx.model_name,
|
|
"provider": ctx.provider,
|
|
**extra,
|
|
}
|
|
|
|
|
|
def _log(ctx: LlmBillingContext, event: LlmBillingEvent, *, status: str = "success", error: str | None = None, **detail: Any) -> None:
|
|
log_operation_event(
|
|
domain=LlmBillingDomain.LLM_BILLING.value,
|
|
module=ctx.source_module or "llm",
|
|
event_type=event.value,
|
|
event_status=status,
|
|
trace_id=ctx.trace_id,
|
|
request_id=ctx.request_id,
|
|
user_id=ctx.user_id,
|
|
project_id=ctx.source_project_id,
|
|
task_id=ctx.celery_task_id,
|
|
step_id=ctx.source_step_id,
|
|
message=_scene_name(ctx),
|
|
detail=_detail(ctx, **detail),
|
|
error=error,
|
|
)
|
|
|
|
|
|
async def _select_fixed_model(db: AsyncSession, ctx: LlmBillingContext) -> None:
|
|
if ctx.model_config_id:
|
|
result = await db.execute(select(ModelConfig).where(ModelConfig.id == ctx.model_config_id).limit(1))
|
|
model = result.scalar_one_or_none()
|
|
else:
|
|
result = await db.execute(
|
|
select(ModelConfig)
|
|
.where(ModelConfig.is_active.is_(True), ModelConfig.deleted_at.is_(None), ModelConfig.provider != "mock")
|
|
.order_by(ModelConfig.priority.desc(), ModelConfig.id.asc())
|
|
.limit(1)
|
|
)
|
|
model = result.scalar_one_or_none()
|
|
if model is None:
|
|
raise LlmBillingConfigurationError("没有可用的真实LLM模型,且本版本禁止Mock或自动降级")
|
|
ctx.model_config_id = model.id
|
|
ctx.model_name = model.model_name
|
|
ctx.provider = model.provider
|
|
ctx.model_parameters_snapshot = {
|
|
"model_config_id": model.id,
|
|
"name": model.name,
|
|
"provider": model.provider,
|
|
"api_base": model.api_base,
|
|
"model_name": model.model_name,
|
|
"max_tokens": model.max_tokens,
|
|
"temperature": model.temperature,
|
|
}
|
|
|
|
|
|
async def _find_execution(db: AsyncSession, ctx: LlmBillingContext, *, for_update: bool = False) -> LlmBillingExecution | None:
|
|
stmt = select(LlmBillingExecution).where(
|
|
LlmBillingExecution.user_id == ctx.user_id,
|
|
LlmBillingExecution.scene_code == (ctx.billing_scene or ""),
|
|
LlmBillingExecution.owner_type == ctx.owner_type,
|
|
LlmBillingExecution.owner_id == ctx.owner_id,
|
|
LlmBillingExecution.business_attempt_no == ctx.attempt_no,
|
|
).limit(1)
|
|
if for_update:
|
|
stmt = stmt.with_for_update()
|
|
result = await db.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
def _state_for_execution(execution: LlmBillingExecution | None) -> LlmBillingLedgerState:
|
|
if execution is None:
|
|
return LlmBillingLedgerState.MISSING
|
|
mapping = {
|
|
LlmBillingExecutionStatus.PRE_DEDUCTED.value: LlmBillingLedgerState.ACTIVE,
|
|
LlmBillingExecutionStatus.PROCESSING.value: LlmBillingLedgerState.ACTIVE,
|
|
LlmBillingExecutionStatus.SUCCEEDED.value: LlmBillingLedgerState.SUCCEEDED,
|
|
LlmBillingExecutionStatus.FINAL_FAILED.value: LlmBillingLedgerState.FINAL_FAILED,
|
|
LlmBillingExecutionStatus.REFUNDED.value: LlmBillingLedgerState.REFUNDED,
|
|
LlmBillingExecutionStatus.REFUND_FAILED.value: LlmBillingLedgerState.INVALID,
|
|
}
|
|
return mapping.get(execution.status, LlmBillingLedgerState.INVALID)
|
|
|
|
|
|
async def pre_deduct(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductResult:
|
|
"""在业务 API 事务内同步足额预扣固定场景积分。"""
|
|
_log(ctx, LlmBillingEvent.PRE_DEDUCT_START, status="started")
|
|
if not ctx.billing_scene:
|
|
raise LlmBillingConfigurationError("LLM固定预扣必须指定billing_scene")
|
|
# 幂等执行记录查询必须位于用户积分事务锁之后。否则两个相同 API 请求
|
|
# 可能都在锁前读到 missing,第二个虽不会重复扣分,却会撞执行记录唯一键。
|
|
await acquire_user_credit_lock(db, ctx.user_id)
|
|
existing = await _find_execution(db, ctx, for_update=True)
|
|
if existing:
|
|
ctx.billing_execution_id = existing.id
|
|
ctx.model_config_id = existing.model_config_id
|
|
ctx.model_name = existing.model_name_snapshot
|
|
ctx.provider = existing.provider_snapshot
|
|
return LlmPreDeductResult(
|
|
amount=to_float(existing.pre_deduct_credits),
|
|
state=_state_for_execution(existing),
|
|
created=False,
|
|
record_id=existing.credit_record_id,
|
|
execution_id=existing.id,
|
|
)
|
|
|
|
await _select_fixed_model(db, ctx)
|
|
policy = await get_llm_billing_policy(
|
|
db,
|
|
scene_code=ctx.billing_scene,
|
|
)
|
|
if not policy.valid:
|
|
_log(ctx, LlmBillingEvent.PRE_DEDUCT_CONFIG_INVALID, status="failed", error=policy.error)
|
|
raise LlmBillingConfigurationError(policy.error or "LLM固定预扣配置无效")
|
|
|
|
scene_name = _scene_name(ctx, policy.scene_name)
|
|
meta = {
|
|
"owner_type": ctx.owner_type,
|
|
"owner_id": ctx.owner_id,
|
|
"attempt_no": ctx.attempt_no,
|
|
"charge_kind": ctx.charge_kind,
|
|
"charge_action": CreditRecordAction.PRE_DEDUCT.value,
|
|
"credit_subject": CreditRecordSubject.TEXT.value,
|
|
"billing_scene": ctx.billing_scene,
|
|
"scene_name_snapshot": scene_name,
|
|
"source_module": ctx.source_module,
|
|
"source_project_id": ctx.source_project_id,
|
|
"source_step_id": ctx.source_step_id,
|
|
"source_step_code": ctx.source_step_code,
|
|
"engine_type": "model",
|
|
"engine_id": ctx.model_config_id,
|
|
"engine_provider": ctx.provider,
|
|
"engine_model_name": ctx.model_name,
|
|
}
|
|
mutation = await deduct_credits_result(
|
|
db,
|
|
user_id=ctx.user_id,
|
|
amount=policy.pre_deduct_credits,
|
|
description=f"{scene_name}固定预扣积分",
|
|
related_id=ctx.related_id or ctx.owner_id,
|
|
biz_key=ctx.pre_deduct_biz_key,
|
|
record_meta=meta,
|
|
record_type="consume",
|
|
request_time=ctx.request_time or utc_now(),
|
|
)
|
|
if mutation.record is None:
|
|
raise LlmBillingStateError("LLM固定预扣流水创建失败")
|
|
execution = LlmBillingExecution(
|
|
id=generate_id(),
|
|
user_id=ctx.user_id,
|
|
scene_code=ctx.billing_scene,
|
|
scene_name_snapshot=scene_name,
|
|
owner_type=ctx.owner_type,
|
|
owner_id=ctx.owner_id,
|
|
business_attempt_no=ctx.attempt_no,
|
|
model_config_id=ctx.model_config_id,
|
|
model_name_snapshot=ctx.model_name,
|
|
provider_snapshot=ctx.provider,
|
|
model_parameters_snapshot=ctx.model_parameters_snapshot,
|
|
billing_policy_id=policy.policy_id,
|
|
billing_policy_version=policy.version,
|
|
request_time=ctx.request_time or utc_now(),
|
|
pre_deduct_credits=policy.pre_deduct_credits,
|
|
credit_record_id=mutation.record.id,
|
|
status=LlmBillingExecutionStatus.PRE_DEDUCTED.value,
|
|
)
|
|
db.add(execution)
|
|
await db.flush()
|
|
mutation.record.llm_billing_execution_id = execution.id
|
|
mutation.record.scene_name_snapshot = scene_name
|
|
ctx.billing_execution_id = execution.id
|
|
_log(ctx, LlmBillingEvent.PRE_DEDUCT_SUCCESS, amount=policy.pre_deduct_credits, credit_record_id=mutation.record.id)
|
|
return LlmPreDeductResult(
|
|
amount=policy.pre_deduct_credits,
|
|
state=LlmBillingLedgerState.ACTIVE,
|
|
created=mutation.created,
|
|
record_id=mutation.record.id,
|
|
execution_id=execution.id,
|
|
)
|
|
|
|
|
|
async def ensure_pre_deducted(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductValidation:
|
|
execution = await _find_execution(db, ctx)
|
|
if execution is None:
|
|
return LlmPreDeductValidation(False, 0.0, LlmBillingLedgerState.MISSING, "pre_deduct_missing")
|
|
ctx.billing_execution_id = execution.id
|
|
ctx.model_config_id = execution.model_config_id
|
|
ctx.model_name = execution.model_name_snapshot
|
|
ctx.provider = execution.provider_snapshot
|
|
ctx.model_parameters_snapshot = execution.model_parameters_snapshot
|
|
state = _state_for_execution(execution)
|
|
can_execute = state == LlmBillingLedgerState.ACTIVE
|
|
if not can_execute:
|
|
_log(ctx, LlmBillingEvent.EXECUTION_BLOCKED, status="failed", execution_status=execution.status)
|
|
else:
|
|
_log(ctx, LlmBillingEvent.EXECUTION_VALIDATE_SUCCESS, execution_status=execution.status)
|
|
return LlmPreDeductValidation(
|
|
can_execute=can_execute,
|
|
amount=to_float(execution.pre_deduct_credits),
|
|
state=state,
|
|
reason=None if can_execute else execution.status,
|
|
pre_deduct_record_id=execution.credit_record_id,
|
|
execution_id=execution.id,
|
|
)
|
|
|
|
|
|
async def get_llm_ledger_states(db: AsyncSession, contexts: Iterable[LlmBillingContext]) -> dict[str, LlmPreDeductValidation]:
|
|
items = list(contexts)
|
|
if not items:
|
|
return {}
|
|
conditions = [
|
|
and_(
|
|
LlmBillingExecution.user_id == ctx.user_id,
|
|
LlmBillingExecution.scene_code == (ctx.billing_scene or ""),
|
|
LlmBillingExecution.owner_type == ctx.owner_type,
|
|
LlmBillingExecution.owner_id == ctx.owner_id,
|
|
LlmBillingExecution.business_attempt_no == ctx.attempt_no,
|
|
)
|
|
for ctx in items
|
|
]
|
|
result = await db.execute(select(LlmBillingExecution).where(or_(*conditions)))
|
|
by_key = {
|
|
(row.user_id, row.scene_code, row.owner_type, row.owner_id, row.business_attempt_no): row
|
|
for row in result.scalars().all()
|
|
}
|
|
output: dict[str, LlmPreDeductValidation] = {}
|
|
for ctx in items:
|
|
row = by_key.get((ctx.user_id, ctx.billing_scene or "", ctx.owner_type, ctx.owner_id, ctx.attempt_no))
|
|
state = _state_for_execution(row)
|
|
output[ctx.pre_deduct_biz_key] = LlmPreDeductValidation(
|
|
can_execute=state == LlmBillingLedgerState.ACTIVE,
|
|
amount=to_float(row.pre_deduct_credits) if row else 0.0,
|
|
state=state,
|
|
reason=None if state == LlmBillingLedgerState.ACTIVE else (row.status if row else "missing"),
|
|
pre_deduct_record_id=row.credit_record_id if row else None,
|
|
execution_id=row.id if row else None,
|
|
)
|
|
return output
|
|
|
|
|
|
async def validate_retryable_previous_attempt(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductValidation:
|
|
previous = replace(ctx, attempt_no=max(1, ctx.attempt_no - 1)) if ctx.attempt_no > 1 else ctx
|
|
validation = (await get_llm_ledger_states(db, [previous]))[previous.pre_deduct_biz_key]
|
|
if ctx.attempt_no <= 1:
|
|
return LlmPreDeductValidation(True, 0.0, LlmBillingLedgerState.MISSING, "first_attempt")
|
|
allowed = validation.state in {
|
|
LlmBillingLedgerState.SUCCEEDED,
|
|
LlmBillingLedgerState.REFUNDED,
|
|
LlmBillingLedgerState.FINAL_FAILED,
|
|
}
|
|
return LlmPreDeductValidation(allowed, validation.amount, validation.state, None if allowed else "previous_attempt_not_final", validation.pre_deduct_record_id, validation.execution_id)
|
|
|
|
|
|
async def log_provider_start(db: AsyncSession, ctx: LlmBillingContext, *, detail: Mapping[str, Any] | None = None) -> None:
|
|
if not ctx.billing_execution_id:
|
|
validation = await ensure_pre_deducted(db, ctx)
|
|
if not validation.can_execute:
|
|
raise LlmBillingStateError("缺少有效固定预扣,禁止调用LLM")
|
|
await create_call_attempt(ctx, detail=detail)
|
|
_log(ctx, LlmBillingEvent.PROVIDER_START, status="started", call_attempt_id=ctx.current_call_attempt_id, detail=dict(detail or {}))
|
|
|
|
|
|
async def log_provider_success(db: AsyncSession, ctx: LlmBillingContext, *, usage: Mapping[str, Any] | None = None) -> None:
|
|
# 先记录内存中的供应商成功事实。即使独立审计事务异常,外层异常处理也
|
|
# 必须按“供应商成功、后处理失败”处理,不能覆盖成 provider failed。
|
|
ctx.provider_call_succeeded = True
|
|
ctx.provider_usage_snapshot = dict(usage or {})
|
|
await finish_call_success(ctx, usage=usage)
|
|
_log(ctx, LlmBillingEvent.PROVIDER_SUCCESS, call_attempt_id=ctx.current_call_attempt_id, usage=dict(usage or {}))
|
|
|
|
|
|
async def log_provider_failure(db: AsyncSession, ctx: LlmBillingContext, *, error: str) -> None:
|
|
if ctx.provider_call_succeeded:
|
|
# 可能是首次成功审计落库失败后进入外层异常处理。先幂等补记真实成功,
|
|
# 再把当前异常记为 postprocess failure。
|
|
await finish_call_success(ctx, usage=ctx.provider_usage_snapshot or {})
|
|
outcome = await finish_call_failure(ctx, error=error)
|
|
event = LlmBillingEvent.POSTPROCESS_FAILURE if outcome == "postprocess_failure" else LlmBillingEvent.PROVIDER_FAILURE
|
|
_log(
|
|
ctx,
|
|
event,
|
|
status="failed",
|
|
call_attempt_id=ctx.current_call_attempt_id,
|
|
failure_stage="postprocess" if outcome == "postprocess_failure" else "provider",
|
|
error=error,
|
|
)
|
|
|
|
|
|
async def record_provider_exception(
|
|
db: AsyncSession,
|
|
ctx: LlmBillingContext,
|
|
exc: Exception,
|
|
) -> tuple[bool, dict[str, Any]]:
|
|
"""记录供应商失败,或“供应商成功但后处理失败”的真实调用事实。"""
|
|
if ctx.provider_call_succeeded:
|
|
usage = dict(ctx.provider_usage_snapshot or {})
|
|
await log_provider_failure(db, ctx, error=str(exc))
|
|
return True, usage
|
|
if isinstance(exc, LlmProviderPostprocessError):
|
|
usage = dict(exc.usage or {})
|
|
await log_provider_success(db, ctx, usage=usage)
|
|
await log_provider_failure(db, ctx, error=str(exc))
|
|
return True, usage
|
|
await log_provider_failure(db, ctx, error=str(exc))
|
|
return False, {}
|
|
|
|
|
|
def log_celery_dispatch_start(ctx: LlmBillingContext) -> None:
|
|
_log(ctx, LlmBillingEvent.CELERY_DISPATCH_START, status="started")
|
|
|
|
|
|
def log_celery_dispatch_success(ctx: LlmBillingContext) -> None:
|
|
_log(ctx, LlmBillingEvent.CELERY_DISPATCH_SUCCESS)
|
|
|
|
|
|
def log_celery_dispatch_failure(ctx: LlmBillingContext, *, error: str) -> None:
|
|
_log(ctx, LlmBillingEvent.CELERY_DISPATCH_FAILURE, status="failed", error=error)
|
|
|
|
|
|
def log_celery_dispatch_compensated(ctx: LlmBillingContext, *, error: str) -> None:
|
|
_log(ctx, LlmBillingEvent.CELERY_DISPATCH_COMPENSATED, status="warning", error=error)
|
|
|
|
|
|
async def mark_business_success(
|
|
db: AsyncSession,
|
|
ctx: LlmBillingContext,
|
|
*,
|
|
usage: Mapping[str, Any],
|
|
description: str | None = None,
|
|
) -> BillingSummary:
|
|
# 调用审计使用独立短事务,必须在当前 Session 锁定 execution 之前完成。
|
|
# 若先锁 execution 再等待独立 Session 更新同一行,会形成自锁死等待。
|
|
if ctx.current_call_attempt_id:
|
|
await finish_call_success(ctx, usage=usage)
|
|
execution = await _find_execution(db, ctx, for_update=True)
|
|
if execution is None:
|
|
raise LlmBillingStateError("LLM固定预扣执行记录不存在")
|
|
ctx.billing_execution_id = execution.id
|
|
created = execution.status != LlmBillingExecutionStatus.SUCCEEDED.value
|
|
if execution.status in {LlmBillingExecutionStatus.REFUNDED.value, LlmBillingExecutionStatus.FINAL_FAILED.value}:
|
|
raise LlmBillingStateError("LLM业务已最终失败并退款,不能再标记成功")
|
|
execution.status = LlmBillingExecutionStatus.SUCCEEDED.value
|
|
execution.completed_at = utc_now()
|
|
call_result = await db.execute(
|
|
select(LlmCallAttempt)
|
|
.where(LlmCallAttempt.billing_execution_id == execution.id)
|
|
.order_by(LlmCallAttempt.call_sequence.desc())
|
|
.limit(1)
|
|
)
|
|
last_call = call_result.scalar_one_or_none()
|
|
if last_call:
|
|
last_call.postprocess_status = "succeeded"
|
|
last_call.postprocess_error = None
|
|
record_result = await db.execute(select(CreditRecord).where(CreditRecord.id == execution.credit_record_id).limit(1).with_for_update())
|
|
record = record_result.scalar_one_or_none()
|
|
if record:
|
|
record.input_tokens = execution.total_input_tokens
|
|
record.output_tokens = execution.total_output_tokens
|
|
record.total_tokens = execution.total_tokens
|
|
record.llm_call_count = execution.total_call_count
|
|
record.llm_success_call_count = execution.successful_call_count
|
|
record.llm_failed_call_count = execution.failed_call_count
|
|
_log(ctx, LlmBillingEvent.BUSINESS_SUCCESS, pre_deduct_credits=to_float(execution.pre_deduct_credits), idempotent=not created)
|
|
return BillingSummary(
|
|
record_id=ctx.owner_id,
|
|
user_id=ctx.user_id,
|
|
items=[
|
|
BillingItem(
|
|
charge_key=ctx.charge_kind,
|
|
amount=to_float(execution.pre_deduct_credits),
|
|
charged=created,
|
|
skipped_reason=None if created else "already_succeeded",
|
|
biz_key=ctx.billing_biz_key,
|
|
attempt_no=ctx.attempt_no,
|
|
)
|
|
],
|
|
)
|
|
|
|
|
|
async def refund_on_final_failure(db: AsyncSession, ctx: LlmBillingContext, *, error: str | None = None) -> BillingSummary:
|
|
from app.services.credit.ledger_service import refund_consumption
|
|
|
|
# 与固定预扣保持 user advisory -> execution -> balance 的统一锁顺序。
|
|
await acquire_user_credit_lock(db, ctx.user_id)
|
|
execution = await _find_execution(db, ctx, for_update=True)
|
|
if execution is None:
|
|
return BillingSummary(ctx.owner_id, ctx.user_id, [])
|
|
ctx.billing_execution_id = execution.id
|
|
if execution.status == LlmBillingExecutionStatus.REFUNDED.value:
|
|
return BillingSummary(
|
|
ctx.owner_id,
|
|
ctx.user_id,
|
|
[BillingItem(ctx.charge_kind, to_float(execution.pre_deduct_credits), False, "already_refunded", ctx.refund_biz_key, ctx.attempt_no)],
|
|
)
|
|
if execution.status == LlmBillingExecutionStatus.SUCCEEDED.value:
|
|
# 调用方只有在业务最终失败时才能进这里;允许从业务成功调用记录转最终失败退款。
|
|
pass
|
|
execution.status = LlmBillingExecutionStatus.FINAL_FAILED.value
|
|
execution.final_error_message = str(error or "业务最终失败")[:1000]
|
|
execution.completed_at = utc_now()
|
|
_log(ctx, LlmBillingEvent.FINAL_FAILURE_START, status="started", error=error)
|
|
try:
|
|
refunded = await refund_consumption(
|
|
db,
|
|
user_id=ctx.user_id,
|
|
refund_for_biz_key=ctx.pre_deduct_biz_key,
|
|
description=f"{execution.scene_name_snapshot}最终失败",
|
|
related_id=ctx.related_id or ctx.owner_id,
|
|
biz_key=f"{ctx.pre_deduct_biz_key}:business-final-refund",
|
|
record_meta={
|
|
"owner_type": ctx.owner_type,
|
|
"owner_id": ctx.owner_id,
|
|
"attempt_no": ctx.attempt_no,
|
|
"charge_kind": ctx.charge_kind,
|
|
"charge_action": CreditRecordAction.REFUND.value,
|
|
"credit_subject": CreditRecordSubject.REFUND.value,
|
|
"billing_scene": ctx.billing_scene,
|
|
"scene_name_snapshot": execution.scene_name_snapshot,
|
|
"source_module": ctx.source_module,
|
|
"source_project_id": ctx.source_project_id,
|
|
"source_step_id": ctx.source_step_id,
|
|
"source_step_code": ctx.source_step_code,
|
|
"llm_billing_execution_id": execution.id,
|
|
},
|
|
refund_time=utc_now(),
|
|
)
|
|
execution.refund_available_credits = refunded.available_amount
|
|
execution.refund_expired_credits = refunded.expired_amount
|
|
execution.status = LlmBillingExecutionStatus.REFUNDED.value
|
|
execution.refunded_at = utc_now()
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.FINAL_FAILURE_REFUND_SUCCESS,
|
|
refund_available=to_float(refunded.available_amount),
|
|
refund_expired=to_float(refunded.expired_amount),
|
|
)
|
|
return BillingSummary(
|
|
ctx.owner_id,
|
|
ctx.user_id,
|
|
[BillingItem(ctx.charge_kind, to_float(refunded.total_amount), True, None, ctx.refund_biz_key, ctx.attempt_no)],
|
|
)
|
|
except Exception as exc:
|
|
execution.status = LlmBillingExecutionStatus.REFUND_FAILED.value
|
|
_log(ctx, LlmBillingEvent.FINAL_FAILURE_REFUND_FAILED, status="failed", error=str(exc))
|
|
raise
|
|
|
|
|
|
async def refund_pre_deduct(db: AsyncSession, ctx: LlmBillingContext, *, reason: str = "failure") -> BillingItem:
|
|
summary = await refund_on_final_failure(db, ctx, error=reason)
|
|
return summary.items[0] if summary.items else BillingItem(ctx.charge_kind, 0.0, False, "missing", ctx.refund_biz_key, ctx.attempt_no)
|
|
|
|
|
|
async def finalize_llm_business_failure(
|
|
ctx: LlmBillingContext,
|
|
*,
|
|
error: str | None = None,
|
|
) -> BillingSummary:
|
|
"""在独立短事务中完成最终失败退款,并确保退款失败状态不会被业务 rollback 吞掉。"""
|
|
try:
|
|
async with AsyncSessionLocal() as session:
|
|
async with session.begin():
|
|
return await refund_on_final_failure(session, ctx, error=error)
|
|
except Exception as exc:
|
|
try:
|
|
async with AsyncSessionLocal() as audit_session:
|
|
async with audit_session.begin():
|
|
execution = await _find_execution(audit_session, ctx, for_update=True)
|
|
if execution is not None and execution.status != LlmBillingExecutionStatus.REFUNDED.value:
|
|
execution.status = LlmBillingExecutionStatus.REFUND_FAILED.value
|
|
execution.final_error_message = str(error or exc)[:1000]
|
|
execution.completed_at = utc_now()
|
|
except Exception:
|
|
_log(ctx, LlmBillingEvent.FINAL_FAILURE_REFUND_FAILED, status="failed", error=str(exc), persist_failed_status=False)
|
|
raise
|