Files
video-gen/video-gen-api/app/services/llm_billing/service.py
T
2026-07-24 09:18:05 +08:00

883 lines
33 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Iterable, Mapping
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_record import (
CreditRecordAction,
CreditRecordBillingScene,
CreditRecordChargeKind,
CreditRecordOwnerType,
CreditRecordSourceModule,
CreditRecordSubject,
)
from app.enums.llm_billing import LlmBillingDomain, LlmBillingEvent, LlmBillingLedgerState
from app.models.credit_record import CreditRecord
from app.models.module_generation_step import ModuleGenerationStep
from app.models.token_usage import TokenUsage
from app.services.credit_record_meta_service import (
CreditRecordMeta,
build_generation_record_prompt_meta,
build_module_step_prompt_meta,
build_shot_video_analysis_meta,
)
from app.services.credits import add_credits_result, calc_text_credits, deduct_credits_result
from app.services.generation.billing_service import BillingItem, BillingSummary
from app.services.llm_billing.config import get_llm_billing_policy
from app.services.llm_billing.context import (
LlmBillingConfigurationError,
LlmBillingContext,
LlmBillingStateError,
LlmHoldResult,
LlmHoldValidation,
)
from app.services.operation_log_service import log_operation_event
from app.utils.exceptions import InsufficientCreditsError
from app.utils.id_gen import generate_id
_LEDGER_QUERY_BATCH_SIZE = 1000
@dataclass(slots=True)
class _LedgerRecords:
state: LlmBillingLedgerState
hold: CreditRecord | None = None
release: CreditRecord | None = None
charge: CreditRecord | None = None
reason: str | None = None
@property
def hold_amount(self) -> float:
return _round2(abs(float(self.hold.amount or 0))) if self.hold else 0.0
def _round2(value: Any) -> float:
try:
return round(float(value or 0), 2)
except (TypeError, ValueError):
return 0.0
def _safe_int(value: Any, default: int = 0) -> int:
try:
if value is None or value == "":
return default
return int(value)
except (TypeError, ValueError):
return default
def _context_detail(ctx: LlmBillingContext, **extra: Any) -> dict[str, Any]:
detail = {
"user_id": ctx.user_id,
"owner_type": ctx.owner_type,
"owner_id": ctx.owner_id,
"attempt_no": ctx.attempt_no,
"charge_kind": ctx.charge_kind,
"billing_scene": ctx.billing_scene,
"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,
"related_id": ctx.related_id,
"hold_biz_key": ctx.hold_biz_key,
"hold_release_biz_key": ctx.hold_release_biz_key,
"charge_biz_key": ctx.charge_biz_key,
"celery_task_id": ctx.celery_task_id,
"provider": ctx.provider,
"model_name": ctx.model_name,
"token_usage_id": ctx.token_usage_id,
}
detail.update({key: value for key, value in extra.items() if value is not None})
return {key: value for key, value in detail.items() if value is not None}
def _log(
ctx: LlmBillingContext,
event: LlmBillingEvent,
*,
status: str = "success",
message: str | None = None,
detail: dict[str, Any] | None = None,
error: str | None = None,
) -> None:
log_operation_event(
domain=LlmBillingDomain.LLM_BILLING.value,
module=ctx.source_module or LlmBillingDomain.LLM_BILLING.value,
event_type=event.value,
event_status=status,
source="app.services.llm_billing.service",
trace_id=ctx.trace_id,
request_id=ctx.request_id,
user_id=ctx.user_id,
project_id=ctx.source_project_id,
task_id=ctx.owner_id,
step_id=ctx.source_step_id,
message=message,
detail=detail or _context_detail(ctx),
error=error,
)
def log_provider_start(ctx: LlmBillingContext, *, detail: Mapping[str, Any] | None = None) -> None:
_log(
ctx,
LlmBillingEvent.PROVIDER_START,
status="started",
detail=_context_detail(ctx, **dict(detail or {})),
)
def log_provider_success(ctx: LlmBillingContext, *, usage: Mapping[str, Any] | None = None) -> None:
usage_snapshot = dict(usage or {})
ctx.provider = str(usage_snapshot.get("provider") or usage_snapshot.get("model_provider") or "") or ctx.provider
ctx.model_name = str(usage_snapshot.get("model_name") or usage_snapshot.get("model") or "") or ctx.model_name
ctx.token_usage_id = str(usage_snapshot.get("token_usage_id") or "") or ctx.token_usage_id
_log(
ctx,
LlmBillingEvent.PROVIDER_SUCCESS,
detail=_context_detail(
ctx,
input_tokens=_safe_int(usage_snapshot.get("input_tokens")),
output_tokens=_safe_int(usage_snapshot.get("output_tokens")),
total_tokens=_safe_int(usage_snapshot.get("total_tokens")),
),
)
def log_provider_failure(ctx: LlmBillingContext, *, error: str) -> None:
_log(
ctx,
LlmBillingEvent.PROVIDER_FAILURE,
status="failed",
detail=_context_detail(ctx, error_type="provider_call_failed"),
error=error,
)
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, detail=_context_detail(ctx, compensation_error=error))
def _hold_meta(ctx: LlmBillingContext, *, action: str) -> CreditRecordMeta:
subject = (
CreditRecordSubject.ANALYSIS.value
if ctx.charge_kind == CreditRecordChargeKind.VIDEO_ANALYSIS.value
else CreditRecordSubject.TEXT.value
)
return CreditRecordMeta(
owner_type=ctx.owner_type,
owner_id=ctx.owner_id,
attempt_no=ctx.attempt_no,
charge_kind=ctx.charge_kind,
charge_action=action,
credit_subject=subject,
media_type="video" if ctx.charge_kind == CreditRecordChargeKind.VIDEO_ANALYSIS.value else None,
billing_scene=ctx.billing_scene,
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,
)
def _action_valid(record: CreditRecord | None, expected: CreditRecordAction) -> bool:
if record is None:
return True
# 兼容旧数据:正式 biz_key 已明确动作、charge_action 为空时仍可识别。
return record.charge_action in (None, expected.value)
def _classify_ledger(
*,
hold: CreditRecord | None,
release: CreditRecord | None,
charge: CreditRecord | None,
) -> _LedgerRecords:
if not _action_valid(hold, CreditRecordAction.HOLD):
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_action_mismatch")
if not _action_valid(release, CreditRecordAction.HOLD_RELEASE):
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_action_mismatch")
if not _action_valid(charge, CreditRecordAction.CHARGE):
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_action_mismatch")
if hold is None:
if release is not None or charge is not None:
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_missing_with_followup")
return _LedgerRecords(LlmBillingLedgerState.MISSING)
if release is None and charge is None:
if _round2(abs(float(hold.amount or 0))) <= 0:
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_amount_not_positive")
return _LedgerRecords(LlmBillingLedgerState.ACTIVE, hold)
if release is not None and charge is None:
return _LedgerRecords(LlmBillingLedgerState.RELEASED, hold, release)
if release is not None and charge is not None:
return _LedgerRecords(LlmBillingLedgerState.CHARGED, hold, release, charge)
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_without_release")
async def _load_ledgers(
db: AsyncSession,
contexts: Iterable[LlmBillingContext],
) -> dict[str, _LedgerRecords]:
context_list = list(contexts)
if not context_list:
return {}
record_map: dict[tuple[str, str], CreditRecord] = {}
all_keys = list(dict.fromkeys(key for ctx in context_list for key in ctx.ledger_biz_keys))
user_ids = list(dict.fromkeys(ctx.user_id for ctx in context_list))
for offset in range(0, len(all_keys), _LEDGER_QUERY_BATCH_SIZE):
chunk = all_keys[offset : offset + _LEDGER_QUERY_BATCH_SIZE]
result = await db.execute(
select(CreditRecord).where(
CreditRecord.user_id.in_(user_ids),
CreditRecord.biz_key.in_(chunk),
)
)
for record in result.scalars().all():
if record.biz_key:
record_map[(str(record.user_id), str(record.biz_key))] = record
output: dict[str, _LedgerRecords] = {}
for ctx in context_list:
hold = record_map.get((ctx.user_id, ctx.hold_biz_key))
release = record_map.get((ctx.user_id, ctx.hold_release_biz_key))
charge = record_map.get((ctx.user_id, ctx.charge_biz_key))
output[ctx.hold_biz_key] = _classify_ledger(hold=hold, release=release, charge=charge)
return output
async def _load_ledger(db: AsyncSession, ctx: LlmBillingContext) -> _LedgerRecords:
return (await _load_ledgers(db, [ctx]))[ctx.hold_biz_key]
async def get_llm_ledger_states(
db: AsyncSession,
contexts: Iterable[LlmBillingContext],
) -> dict[str, LlmHoldValidation]:
"""批量读取 attempt 的三类流水;供恢复任务收集 ID 后统一过滤。"""
context_list = list(contexts)
ledgers = await _load_ledgers(db, context_list)
return {
ctx.hold_biz_key: LlmHoldValidation(
can_execute=ledgers[ctx.hold_biz_key].state == LlmBillingLedgerState.ACTIVE,
amount=ledgers[ctx.hold_biz_key].hold_amount,
state=ledgers[ctx.hold_biz_key].state,
reason=ledgers[ctx.hold_biz_key].reason,
hold_record_id=ledgers[ctx.hold_biz_key].hold.id if ledgers[ctx.hold_biz_key].hold else None,
)
for ctx in context_list
}
async def start_hold(db: AsyncSession, ctx: LlmBillingContext) -> LlmHoldResult:
# 幂等/异常 attempt 优先由已落库流水判定;只有全新 attempt 才读取配置。
ledger = await _load_ledger(db, ctx)
# 配置可能在任务执行期间被关闭或修改:已经存在的 active HOLD 必须继续沿用,
# 否则会留下永久冻结流水。只有“没有任何历史流水”的新 attempt 才允许按关闭配置绕过。
if ledger.state == LlmBillingLedgerState.ACTIVE and ledger.hold:
amount = ledger.hold_amount
ctx.hold_credits = amount
_log(
ctx,
LlmBillingEvent.HOLD_SUCCESS,
detail=_context_detail(
ctx,
hold_credits=amount,
hold_record_id=ledger.hold.id,
idempotent=True,
ledger_state=ledger.state.value,
),
)
return LlmHoldResult(amount, ledger.state, created=False, record_id=ledger.hold.id)
if ledger.state != LlmBillingLedgerState.MISSING:
error = f"当前attempt账务状态为{ledger.state.value},不能复用旧预扣"
_log(
ctx,
LlmBillingEvent.EXECUTION_BLOCKED,
status="failed",
detail=_context_detail(ctx, ledger_state=ledger.state.value, reason=ledger.reason),
error=error,
)
raise LlmBillingStateError(error)
policy = await get_llm_billing_policy(
db,
config_key=ctx.hold_config_key,
explicit_hold_credits=ctx.hold_credits,
)
if policy.bypassed:
_log(
ctx,
LlmBillingEvent.HOLD_BYPASSED,
status="skipped",
detail=_context_detail(ctx, ledger_state=LlmBillingLedgerState.BILLING_BYPASSED.value),
)
return LlmHoldResult(
0.0,
LlmBillingLedgerState.BILLING_BYPASSED,
reason="billing_disabled",
)
if not policy.valid:
_log(
ctx,
LlmBillingEvent.HOLD_CONFIG_INVALID,
status="failed",
detail=_context_detail(
ctx,
hold_credits=policy.hold_credits,
config_key=policy.config_key,
config_source=policy.source_key,
),
error=policy.error,
)
raise LlmBillingConfigurationError(policy.error or "LLM计费配置无效")
amount = policy.hold_credits
ctx.hold_credits = amount
_log(
ctx,
LlmBillingEvent.HOLD_START,
status="started",
detail=_context_detail(
ctx,
hold_credits=amount,
config_key=policy.config_key,
config_source=policy.source_key,
),
)
try:
mutation = await deduct_credits_result(
db,
user_id=ctx.user_id,
amount=amount,
description=f"{ctx.description_prefix}预扣积分",
related_id=ctx.related_id or ctx.owner_id,
biz_key=ctx.hold_biz_key,
record_meta=_hold_meta(ctx, action=CreditRecordAction.HOLD.value),
allow_negative=False,
)
except InsufficientCreditsError:
_log(
ctx,
LlmBillingEvent.HOLD_INSUFFICIENT,
status="failed",
detail=_context_detail(ctx, hold_credits=amount),
error="积分不足,无法预扣",
)
raise
if not mutation.created:
# 并发幂等命中后重新读取三类流水,避免复用已被另一事务释放的 HOLD。
ledger = await _load_ledger(db, ctx)
if ledger.state != LlmBillingLedgerState.ACTIVE or ledger.hold is None:
error = f"并发预扣后账务状态为{ledger.state.value},拒绝继续执行"
_log(
ctx,
LlmBillingEvent.EXECUTION_BLOCKED,
status="failed",
detail=_context_detail(ctx, ledger_state=ledger.state.value),
error=error,
)
raise LlmBillingStateError(error)
mutation_record = ledger.hold
amount = ledger.hold_amount
else:
mutation_record = mutation.record
_log(
ctx,
LlmBillingEvent.HOLD_SUCCESS,
detail=_context_detail(
ctx,
hold_credits=amount,
hold_record_id=mutation_record.id if mutation_record else None,
idempotent=not mutation.created,
balance_before=mutation.balance_before,
balance_after=mutation.balance_after,
ledger_state=LlmBillingLedgerState.ACTIVE.value,
),
)
return LlmHoldResult(
amount,
LlmBillingLedgerState.ACTIVE,
created=mutation.created,
record_id=mutation_record.id if mutation_record else None,
)
async def ensure_hold_exists(db: AsyncSession, ctx: LlmBillingContext) -> LlmHoldValidation:
"""worker 调用模型前确认计费绕过或 active HOLD;不在 worker 首次预扣。"""
_log(ctx, LlmBillingEvent.EXECUTION_VALIDATE_START, status="started")
ledger = await _load_ledger(db, ctx)
# 先尊重已经落库的 attempt 账务状态,再处理当前配置。这样关闭计费不会
# 把运行中的 active HOLD 遗留为永久冻结;而新建且没有 HOLD 的任务才会绕过。
if ledger.state == LlmBillingLedgerState.ACTIVE and ledger.hold:
amount = ledger.hold_amount
ctx.hold_credits = amount
result = LlmHoldValidation(True, amount, ledger.state, hold_record_id=ledger.hold.id)
_log(
ctx,
LlmBillingEvent.EXECUTION_VALIDATE_SUCCESS,
detail=_context_detail(
ctx,
hold_credits=amount,
hold_record_id=ledger.hold.id,
ledger_state=ledger.state.value,
),
)
return result
if ledger.state == LlmBillingLedgerState.MISSING:
policy = await get_llm_billing_policy(
db,
config_key=ctx.hold_config_key,
explicit_hold_credits=None,
)
else:
policy = None
if policy is not None and policy.bypassed:
result = LlmHoldValidation(
True,
0.0,
LlmBillingLedgerState.BILLING_BYPASSED,
"billing_disabled",
)
_log(
ctx,
LlmBillingEvent.EXECUTION_VALIDATE_SUCCESS,
detail=_context_detail(
ctx,
ledger_state=result.state.value,
billing_bypassed=True,
),
)
return result
if policy is not None and not policy.valid:
result = LlmHoldValidation(False, 0.0, LlmBillingLedgerState.INVALID, "billing_config_invalid")
_log(
ctx,
LlmBillingEvent.EXECUTION_BLOCKED,
status="failed",
detail=_context_detail(ctx, ledger_state=result.state.value),
error=policy.error,
)
return result
result = LlmHoldValidation(
False,
ledger.hold_amount,
ledger.state,
ledger.reason or f"ledger_{ledger.state.value}",
ledger.hold.id if ledger.hold else None,
)
event = (
LlmBillingEvent.HOLD_MISSING
if ledger.state == LlmBillingLedgerState.MISSING
else LlmBillingEvent.EXECUTION_BLOCKED
)
_log(
ctx,
event,
status="failed",
detail=_context_detail(
ctx,
hold_credits=result.amount,
hold_record_id=result.hold_record_id,
ledger_state=result.state.value,
skip_reason=result.reason,
),
error="LLM预扣不是有效冻结状态,拒绝调用模型",
)
return result
async def _release_active_hold(
db: AsyncSession,
ctx: LlmBillingContext,
*,
hold_record: CreditRecord,
reason: str,
) -> BillingItem:
amount = _round2(abs(float(hold_record.amount or 0)))
ctx.hold_credits = amount
if amount <= 0:
_log(ctx, LlmBillingEvent.HOLD_RELEASE_SKIPPED, status="skipped", detail=_context_detail(ctx, hold_record_id=hold_record.id, reason=reason, skip_reason="hold_amount_not_positive"))
return BillingItem(
charge_key=CreditRecordAction.HOLD_RELEASE.value,
amount=0.0,
charged=False,
skipped_reason="hold_amount_not_positive",
biz_key=ctx.hold_release_biz_key,
attempt_no=ctx.attempt_no,
)
_log(ctx, LlmBillingEvent.HOLD_RELEASE_START, status="started", detail=_context_detail(ctx, hold_credits=amount, hold_record_id=hold_record.id, reason=reason))
mutation = await add_credits_result(
db,
user_id=ctx.user_id,
amount=amount,
description=f"{ctx.description_prefix}预扣积分释放",
related_id=ctx.related_id or ctx.owner_id,
record_type="refund",
biz_key=ctx.hold_release_biz_key,
refund_for_biz_key=ctx.hold_biz_key,
record_meta=_hold_meta(ctx, action=CreditRecordAction.HOLD_RELEASE.value),
)
_log(
ctx,
LlmBillingEvent.HOLD_RELEASE_SUCCESS,
detail=_context_detail(
ctx,
hold_credits=amount,
hold_record_id=hold_record.id,
hold_release_record_id=mutation.record.id if mutation.record else None,
reason=reason,
idempotent=not mutation.created,
balance_before=mutation.balance_before,
balance_after=mutation.balance_after,
),
)
return BillingItem(
charge_key=CreditRecordAction.HOLD_RELEASE.value,
amount=amount,
charged=False,
skipped_reason=None if mutation.created else "already_released",
biz_key=ctx.hold_release_biz_key,
attempt_no=ctx.attempt_no,
)
async def release_hold(db: AsyncSession, ctx: LlmBillingContext, *, reason: str = "failure") -> BillingItem:
ledger = await _load_ledger(db, ctx)
# 即使管理员已经关闭计费,历史 active HOLD 也必须按真实冻结流水释放。
if ledger.state == LlmBillingLedgerState.ACTIVE and ledger.hold:
return await _release_active_hold(db, ctx, hold_record=ledger.hold, reason=reason)
if ledger.state in (LlmBillingLedgerState.RELEASED, LlmBillingLedgerState.CHARGED):
amount = _round2(abs(float(ledger.release.amount or 0))) if ledger.release else ledger.hold_amount
_log(
ctx,
LlmBillingEvent.HOLD_RELEASE_SKIPPED,
status="skipped",
detail=_context_detail(
ctx,
reason=reason,
hold_credits=amount,
ledger_state=ledger.state.value,
skip_reason="already_released",
idempotent=True,
),
)
return BillingItem(
charge_key=CreditRecordAction.HOLD_RELEASE.value,
amount=amount,
charged=False,
skipped_reason="already_released",
biz_key=ctx.hold_release_biz_key,
attempt_no=ctx.attempt_no,
)
if ledger.state == LlmBillingLedgerState.MISSING:
policy = await get_llm_billing_policy(db, config_key=ctx.hold_config_key)
else:
policy = None
if policy is not None and policy.bypassed:
_log(
ctx,
LlmBillingEvent.HOLD_RELEASE_SKIPPED,
status="skipped",
detail=_context_detail(
ctx,
reason=reason,
ledger_state=LlmBillingLedgerState.BILLING_BYPASSED.value,
skip_reason="billing_disabled",
),
)
return BillingItem(
charge_key=CreditRecordAction.HOLD_RELEASE.value,
amount=0.0,
charged=False,
skipped_reason="billing_disabled",
biz_key=ctx.hold_release_biz_key,
attempt_no=ctx.attempt_no,
)
_log(
ctx,
LlmBillingEvent.HOLD_RELEASE_SKIPPED,
status="skipped",
detail=_context_detail(
ctx,
reason=reason,
ledger_state=ledger.state.value,
skip_reason=ledger.reason or ledger.state.value,
),
error="没有可释放的有效LLM预扣流水",
)
return BillingItem(
charge_key=CreditRecordAction.HOLD_RELEASE.value,
amount=0.0,
charged=False,
skipped_reason=ledger.reason or ledger.state.value,
biz_key=ctx.hold_release_biz_key,
attempt_no=ctx.attempt_no,
)
async def release_on_failure(db: AsyncSession, ctx: LlmBillingContext, *, error: str | None = None) -> BillingSummary:
_log(ctx, LlmBillingEvent.FAILURE_RELEASE_START, status="started", detail=_context_detail(ctx, error=error), error=error)
item = await release_hold(db, ctx, reason="failure")
if item.amount > 0 and not item.skipped_reason:
_log(ctx, LlmBillingEvent.FAILURE_RELEASE_SUCCESS, detail=_context_detail(ctx, hold_credits=item.amount, error=error), error=error)
else:
_log(ctx, LlmBillingEvent.FAILURE_RELEASE_SKIPPED, status="skipped", detail=_context_detail(ctx, hold_credits=item.amount, error=error, skip_reason=item.skipped_reason or "hold_not_active"), error=error)
return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[item])
async def _build_charge_meta(db: AsyncSession, ctx: LlmBillingContext, usage: Mapping[str, Any]) -> CreditRecordMeta:
usage_snapshot = dict(usage or {})
ctx.provider = str(usage_snapshot.get("provider") or usage_snapshot.get("model_provider") or "") or ctx.provider
ctx.model_name = str(usage_snapshot.get("model_name") or usage_snapshot.get("model") or "") or ctx.model_name
ctx.token_usage_id = str(usage_snapshot.get("token_usage_id") or "") or ctx.token_usage_id
if ctx.owner_type == CreditRecordOwnerType.GENERATION_RECORD.value:
return await build_generation_record_prompt_meta(
db,
record_id=ctx.owner_id,
attempt_no=ctx.attempt_no,
charge_kind=ctx.charge_kind,
usage=usage_snapshot,
)
if ctx.owner_type == CreditRecordOwnerType.MODULE_GENERATION_STEP.value:
return await build_module_step_prompt_meta(
db,
step_id=ctx.owner_id,
attempt_no=ctx.attempt_no,
usage=usage_snapshot,
)
if ctx.charge_kind == CreditRecordChargeKind.VIDEO_ANALYSIS.value:
if not usage_snapshot.get("token_usage_id"):
input_tokens = _safe_int(usage_snapshot.get("input_tokens"))
output_tokens = _safe_int(usage_snapshot.get("output_tokens"))
token_usage = TokenUsage(
id=generate_id(),
model_config_id=usage_snapshot.get("model_config_id"),
user_id=ctx.user_id,
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=_safe_int(usage_snapshot.get("total_tokens"), input_tokens + output_tokens),
owner_type=ctx.owner_type,
owner_id=ctx.owner_id,
biz_key=ctx.charge_biz_key,
source_module=ctx.source_module or CreditRecordSourceModule.SHOT_REPLICATE.value,
source_step_code=ctx.source_step_code,
)
db.add(token_usage)
await db.flush()
usage_snapshot["token_usage_id"] = token_usage.id
ctx.token_usage_id = token_usage.id
return await build_shot_video_analysis_meta(
db,
owner_type=ctx.owner_type,
owner_id=ctx.owner_id,
attempt_no=ctx.attempt_no,
usage=usage_snapshot,
billing_scene=ctx.billing_scene or CreditRecordBillingScene.SHOT_VIDEO_ANALYSIS.value,
source_project_id=ctx.source_project_id,
source_step_id=ctx.source_step_id,
)
return CreditRecordMeta(
owner_type=ctx.owner_type,
owner_id=ctx.owner_id,
attempt_no=ctx.attempt_no,
charge_kind=ctx.charge_kind,
charge_action=CreditRecordAction.CHARGE.value,
credit_subject=CreditRecordSubject.TEXT.value,
billing_scene=ctx.billing_scene,
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,
token_usage_id=usage_snapshot.get("token_usage_id"),
input_tokens=_safe_int(usage_snapshot.get("input_tokens")),
output_tokens=_safe_int(usage_snapshot.get("output_tokens")),
total_tokens=_safe_int(usage_snapshot.get("total_tokens")),
)
async def _settle_success_impl(
db: AsyncSession,
ctx: LlmBillingContext,
*,
usage: Mapping[str, Any],
description: str | None = None,
) -> BillingSummary:
ledger = await _load_ledger(db, ctx)
if ledger.state == LlmBillingLedgerState.CHARGED and ledger.hold and ledger.release and ledger.charge:
release_item = BillingItem(
charge_key=CreditRecordAction.HOLD_RELEASE.value,
amount=abs(_round2(ledger.release.amount)),
charged=False,
skipped_reason="already_released",
biz_key=ctx.hold_release_biz_key,
attempt_no=ctx.attempt_no,
)
charge_item = BillingItem(
charge_key=ctx.charge_kind,
amount=abs(_round2(ledger.charge.amount)),
charged=False,
skipped_reason="already_charged",
biz_key=ctx.charge_biz_key,
attempt_no=ctx.attempt_no,
)
_log(
ctx,
LlmBillingEvent.SETTLE_SUCCESS,
detail=_context_detail(
ctx,
actual_credits=charge_item.amount,
ledger_state=ledger.state.value,
idempotent=True,
),
)
return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[release_item, charge_item])
# 新任务在关闭计费时没有 HOLD,成功后直接绕过;历史 active HOLD 则必须继续结算。
if ledger.state == LlmBillingLedgerState.MISSING:
policy = await get_llm_billing_policy(db, config_key=ctx.hold_config_key)
else:
policy = None
if policy is not None and policy.bypassed:
_log(
ctx,
LlmBillingEvent.SETTLE_SUCCESS,
status="skipped",
detail=_context_detail(
ctx,
ledger_state=LlmBillingLedgerState.BILLING_BYPASSED.value,
billing_bypassed=True,
),
)
return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[])
if ledger.state != LlmBillingLedgerState.ACTIVE or ledger.hold is None:
# 统一由 settle_success 外层记录一次 SETTLE_FAILED,避免同一异常产生重复日志。
raise LlmBillingStateError(
f"当前attempt账务状态为{ledger.state.value},不能执行成功结算"
)
_log(ctx, LlmBillingEvent.SETTLE_START, status="started", detail=_context_detail(ctx, ledger_state=ledger.state.value, usage=dict(usage or {})))
release_item = await _release_active_hold(db, ctx, hold_record=ledger.hold, reason="success")
input_tokens = _safe_int((usage or {}).get("input_tokens"))
output_tokens = _safe_int((usage or {}).get("output_tokens"))
amount = await calc_text_credits(db, input_tokens, output_tokens)
meta = await _build_charge_meta(db, ctx, usage)
if meta.charge_action is None:
meta.charge_action = CreditRecordAction.CHARGE.value
meta.billing_scene = meta.billing_scene or ctx.billing_scene
meta.source_module = meta.source_module or ctx.source_module
meta.source_project_id = meta.source_project_id or ctx.source_project_id
meta.source_step_id = meta.source_step_id or ctx.source_step_id
meta.source_step_code = meta.source_step_code or ctx.source_step_code
mutation = await deduct_credits_result(
db,
user_id=ctx.user_id,
amount=amount,
description=description or f"{ctx.description_prefix}真实扣费",
related_id=ctx.related_id or ctx.owner_id,
biz_key=ctx.charge_biz_key,
record_meta=meta,
allow_negative=True,
create_zero_record=True,
)
charged_amount = mutation.amount
charge_item = BillingItem(
charge_key=ctx.charge_kind,
amount=charged_amount,
charged=mutation.created,
skipped_reason=None if mutation.created else "already_charged",
biz_key=ctx.charge_biz_key,
attempt_no=ctx.attempt_no,
)
if ctx.owner_type == CreditRecordOwnerType.MODULE_GENERATION_STEP.value:
result = await db.execute(select(ModuleGenerationStep).where(ModuleGenerationStep.id == ctx.owner_id).limit(1))
step = result.scalar_one_or_none()
if step:
step.token_usage_id = meta.token_usage_id
step.model_config_id = (usage or {}).get("model_config_id")
step.input_tokens = meta.input_tokens
step.output_tokens = meta.output_tokens
step.total_tokens = meta.total_tokens
step.text_credits_cost = charged_amount
after_balance = mutation.balance_after
_log(
ctx,
LlmBillingEvent.CHARGE_SUCCESS,
detail=_context_detail(
ctx,
actual_credits=charged_amount,
charge_record_id=mutation.record.id if mutation.record else None,
idempotent=not mutation.created,
balance_before=mutation.balance_before,
balance_after=after_balance,
allow_negative=True,
),
)
if after_balance < 0:
_log(ctx, LlmBillingEvent.CHARGE_NEGATIVE_BALANCE, status="warning", detail=_context_detail(ctx, actual_credits=charged_amount, balance_after=after_balance, allow_negative=True))
_log(ctx, LlmBillingEvent.SETTLE_SUCCESS, detail=_context_detail(ctx, actual_credits=charged_amount, balance_after=after_balance, ledger_state=LlmBillingLedgerState.CHARGED.value, idempotent=not mutation.created))
return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[release_item, charge_item])
async def settle_success(
db: AsyncSession,
ctx: LlmBillingContext,
*,
usage: Mapping[str, Any],
description: str | None = None,
) -> BillingSummary:
"""成功结算统一入口;任何异常都留下可检索的 SETTLE_FAILED 日志。"""
try:
return await _settle_success_impl(
db,
ctx,
usage=usage,
description=description,
)
except Exception as exc:
_log(
ctx,
LlmBillingEvent.SETTLE_FAILED,
status="failed",
detail=_context_detail(ctx, error_type=type(exc).__name__),
error=str(exc),
)
raise