积分消耗明细改版V1
This commit is contained in:
@@ -8,7 +8,12 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_admin_user, get_db
|
||||
from app.enums.credit_balance import CreditBalanceSourceType
|
||||
from app.enums.credit_balance import (
|
||||
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
|
||||
CREDIT_BALANCE_STATUS_LABELS,
|
||||
CREDIT_LEVEL_LABELS,
|
||||
CreditBalanceSourceType,
|
||||
)
|
||||
from app.models.credit.balance import UserCreditBalance
|
||||
from app.models.credit.product import CreditProduct
|
||||
from app.models.user import User
|
||||
@@ -248,7 +253,9 @@ async def list_user_credit_balances(
|
||||
{
|
||||
"id": item.id,
|
||||
"credit_level": item.credit_level,
|
||||
"credit_level_label": CREDIT_LEVEL_LABELS.get(item.credit_level, item.credit_level),
|
||||
"source_type": item.source_type,
|
||||
"source_type_label": CREDIT_BALANCE_SOURCE_TYPE_LABELS.get(item.source_type, item.source_type),
|
||||
"source_id": item.source_id,
|
||||
"grant_amount": float(item.grant_amount),
|
||||
"unspent_amount": float(item.unspent_amount),
|
||||
@@ -258,7 +265,8 @@ async def list_user_credit_balances(
|
||||
"valid_from": item.valid_from,
|
||||
"expires_at": item.expires_at,
|
||||
"last_usable_at": last_usable_at(item.expires_at),
|
||||
"status": effective_balance_status(item, request_time=checked_at),
|
||||
"status": (status_value := effective_balance_status(item, request_time=checked_at)),
|
||||
"status_label": CREDIT_BALANCE_STATUS_LABELS.get(status_value, status_value),
|
||||
}
|
||||
for item in result.scalars().all()
|
||||
]
|
||||
|
||||
@@ -16,13 +16,29 @@ from app.utils.id_gen import generate_id
|
||||
router = APIRouter(prefix="/admin/llm-billing", tags=["admin-llm-billing"])
|
||||
|
||||
|
||||
def _policy_out(policy: LlmBillingPolicyModel) -> dict:
|
||||
# 数据库 pre_deduct_credits 是历史物理字段名;Admin API 统一输出 charge_credits。
|
||||
return {
|
||||
"id": policy.id,
|
||||
"scene_code": policy.scene_code,
|
||||
"scene_name": policy.scene_name,
|
||||
"charge_credits": float(policy.pre_deduct_credits),
|
||||
"is_active": policy.is_active,
|
||||
"version": policy.version,
|
||||
"created_by": policy.created_by,
|
||||
"updated_by": policy.updated_by,
|
||||
"created_at": policy.created_at,
|
||||
"updated_at": policy.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/policies")
|
||||
async def list_policies(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(LlmBillingPolicyModel).order_by(LlmBillingPolicyModel.scene_code))
|
||||
return list(result.scalars().all())
|
||||
return [_policy_out(item) for item in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/policies")
|
||||
@@ -33,14 +49,20 @@ async def create_policy(
|
||||
):
|
||||
if data.scene_code not in LLM_BILLING_SCENE_LABELS:
|
||||
raise HTTPException(status_code=400, detail="不支持的LLM业务场景")
|
||||
existing = await db.execute(select(LlmBillingPolicyModel.id).where(LlmBillingPolicyModel.scene_code == data.scene_code).limit(1))
|
||||
existing = await db.execute(
|
||||
select(LlmBillingPolicyModel.id)
|
||||
.where(LlmBillingPolicyModel.scene_code == data.scene_code)
|
||||
.limit(1)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="该LLM业务场景已存在")
|
||||
|
||||
policy = LlmBillingPolicyModel(
|
||||
id=generate_id(),
|
||||
scene_code=data.scene_code,
|
||||
scene_name=LLM_BILLING_SCENE_LABELS[data.scene_code],
|
||||
pre_deduct_credits=data.pre_deduct_credits,
|
||||
# 保留历史物理字段,不做无业务价值的数据库重命名迁移。
|
||||
pre_deduct_credits=data.charge_credits,
|
||||
is_active=data.is_active,
|
||||
version=1,
|
||||
created_by=admin.id,
|
||||
@@ -48,8 +70,17 @@ async def create_policy(
|
||||
)
|
||||
db.add(policy)
|
||||
await db.flush()
|
||||
log_operation_event(domain="llm_billing", module="admin", event_type="LLM_BILLING_POLICY_CREATED", user_id=admin.id, detail={"scene_code": policy.scene_code, "credits": float(policy.pre_deduct_credits)})
|
||||
return policy
|
||||
policy_id = policy.id
|
||||
scene_code = policy.scene_code
|
||||
charge_credits = float(policy.pre_deduct_credits)
|
||||
log_operation_event(
|
||||
domain="llm_billing",
|
||||
module="admin",
|
||||
event_type="LLM_BILLING_POLICY_CREATED",
|
||||
user_id=admin.id,
|
||||
detail={"scene_code": scene_code, "charge_credits": charge_credits, "policy_id": policy_id},
|
||||
)
|
||||
return _policy_out(policy)
|
||||
|
||||
|
||||
@router.put("/policies/{policy_id}")
|
||||
@@ -59,20 +90,41 @@ async def update_policy(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(LlmBillingPolicyModel).where(LlmBillingPolicyModel.id == policy_id).limit(1).with_for_update())
|
||||
result = await db.execute(
|
||||
select(LlmBillingPolicyModel)
|
||||
.where(LlmBillingPolicyModel.id == policy_id)
|
||||
.limit(1)
|
||||
.with_for_update()
|
||||
)
|
||||
policy = result.scalar_one_or_none()
|
||||
if not policy:
|
||||
raise HTTPException(status_code=404, detail="LLM计费场景不存在")
|
||||
raise HTTPException(status_code=404, detail="LLM积分场景不存在")
|
||||
|
||||
payload = data.model_dump(exclude_unset=True)
|
||||
payload.pop("scene_name", None)
|
||||
for key, value in payload.items():
|
||||
setattr(policy, key, value)
|
||||
if "charge_credits" in payload:
|
||||
policy.pre_deduct_credits = payload.pop("charge_credits")
|
||||
if "is_active" in payload:
|
||||
policy.is_active = payload["is_active"]
|
||||
policy.scene_name = LLM_BILLING_SCENE_LABELS.get(policy.scene_code, policy.scene_name)
|
||||
policy.version += 1
|
||||
policy.updated_by = admin.id
|
||||
await db.flush()
|
||||
log_operation_event(domain="llm_billing", module="admin", event_type="LLM_BILLING_POLICY_UPDATED", user_id=admin.id, detail={"scene_code": policy.scene_code, "version": policy.version})
|
||||
return policy
|
||||
|
||||
log_operation_event(
|
||||
domain="llm_billing",
|
||||
module="admin",
|
||||
event_type="LLM_BILLING_POLICY_UPDATED",
|
||||
user_id=admin.id,
|
||||
detail={
|
||||
"scene_code": policy.scene_code,
|
||||
"policy_id": policy.id,
|
||||
"version": policy.version,
|
||||
"charge_credits": float(policy.pre_deduct_credits),
|
||||
"is_active": policy.is_active,
|
||||
},
|
||||
)
|
||||
return _policy_out(policy)
|
||||
|
||||
|
||||
@router.get("/executions")
|
||||
@@ -85,5 +137,12 @@ async def list_executions(
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
items, total = await list_executions_with_calls(db, page=page, page_size=page_size, scene_code=scene_code, status=status, user_id=user_id)
|
||||
items, total = await list_executions_with_calls(
|
||||
db,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
scene_code=scene_code,
|
||||
status=status,
|
||||
user_id=user_id,
|
||||
)
|
||||
return {"items": items, "total": total}
|
||||
|
||||
@@ -532,7 +532,7 @@ async def create_shot_task_set(
|
||||
task_set, created_new = await create_task_set(db, current_user=current_user, req=req)
|
||||
task_set_id = str(task_set.id)
|
||||
if not created_new:
|
||||
# 幂等重复请求不重复预扣和投递;已有 pending 任务由原投递或恢复任务继续处理。
|
||||
# 幂等重复请求不重复消费积分和投递;已有 pending 任务由原投递或恢复任务继续处理。
|
||||
await db.rollback()
|
||||
return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id)
|
||||
analysis_attempt_no = max(1, int(task_set.analysis_attempt_no or 1))
|
||||
|
||||
@@ -13,6 +13,11 @@ CREDIT_LEVEL_SORT = {
|
||||
CreditLevel.GENERAL.value: 20,
|
||||
}
|
||||
|
||||
CREDIT_LEVEL_LABELS = {
|
||||
CreditLevel.PROMOTIONAL.value: "活动积分",
|
||||
CreditLevel.GENERAL.value: "普通积分",
|
||||
}
|
||||
|
||||
|
||||
class CreditBalanceSourceType(StrEnum):
|
||||
REGISTER_GIFT = "register_gift"
|
||||
@@ -26,6 +31,19 @@ class CreditBalanceSourceType(StrEnum):
|
||||
BUSINESS_REFUND = "business_refund"
|
||||
|
||||
|
||||
CREDIT_BALANCE_SOURCE_TYPE_LABELS = {
|
||||
CreditBalanceSourceType.REGISTER_GIFT.value: "注册赠送",
|
||||
CreditBalanceSourceType.DAILY_LOGIN.value: "每日登录赠送",
|
||||
CreditBalanceSourceType.SIGN_IN.value: "签到赠送",
|
||||
CreditBalanceSourceType.ACTIVITY.value: "活动赠送",
|
||||
CreditBalanceSourceType.ADMIN_GRANT.value: "管理员赠送",
|
||||
CreditBalanceSourceType.SUBSCRIPTION_GRANT.value: "订阅积分发放",
|
||||
CreditBalanceSourceType.CREDIT_ADDON.value: "积分增值包",
|
||||
CreditBalanceSourceType.LEGACY_MIGRATION.value: "历史积分迁移",
|
||||
CreditBalanceSourceType.BUSINESS_REFUND.value: "业务退款",
|
||||
}
|
||||
|
||||
|
||||
class CreditBalanceStatus(StrEnum):
|
||||
SCHEDULED = "scheduled"
|
||||
ACTIVE = "active"
|
||||
@@ -35,6 +53,16 @@ class CreditBalanceStatus(StrEnum):
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
CREDIT_BALANCE_STATUS_LABELS = {
|
||||
CreditBalanceStatus.SCHEDULED.value: "待生效",
|
||||
CreditBalanceStatus.ACTIVE.value: "有效",
|
||||
CreditBalanceStatus.CONSUMED.value: "已用完",
|
||||
CreditBalanceStatus.EXPIRED.value: "已过期",
|
||||
CreditBalanceStatus.REVOKED.value: "已撤销",
|
||||
CreditBalanceStatus.CANCELLED.value: "已取消",
|
||||
}
|
||||
|
||||
|
||||
class CreditAllocationAction(StrEnum):
|
||||
GRANT = "grant"
|
||||
CONSUME = "consume"
|
||||
@@ -44,3 +72,15 @@ class CreditAllocationAction(StrEnum):
|
||||
REVOKE = "revoke"
|
||||
UPGRADE_SOURCE_TRANSFER_OUT = "upgrade_source_transfer_out"
|
||||
UPGRADE_SOURCE_TRANSFER_IN = "upgrade_source_transfer_in"
|
||||
|
||||
|
||||
CREDIT_ALLOCATION_ACTION_LABELS = {
|
||||
CreditAllocationAction.GRANT.value: "发放",
|
||||
CreditAllocationAction.CONSUME.value: "消费",
|
||||
CreditAllocationAction.REFUND_AVAILABLE.value: "有效积分退款",
|
||||
CreditAllocationAction.REFUND_EXPIRED.value: "过期积分退款",
|
||||
CreditAllocationAction.EXPIRE.value: "积分过期",
|
||||
CreditAllocationAction.REVOKE.value: "积分撤销",
|
||||
CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_OUT.value: "升级积分转出",
|
||||
CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_IN.value: "升级积分转入",
|
||||
}
|
||||
|
||||
@@ -126,11 +126,11 @@ class CreditRecordBillingScene(str, Enum):
|
||||
|
||||
|
||||
CREDIT_RECORD_ACTION_LABELS = {
|
||||
CreditRecordAction.CHARGE.value: "真实扣费",
|
||||
CreditRecordAction.PRE_DEDUCT.value: "固定预扣",
|
||||
CreditRecordAction.CHARGE.value: "真实消费",
|
||||
CreditRecordAction.PRE_DEDUCT.value: "历史固定预扣",
|
||||
CreditRecordAction.REFUND.value: "真实退款",
|
||||
CreditRecordAction.HOLD.value: "预扣占用",
|
||||
CreditRecordAction.HOLD_RELEASE.value: "预扣释放",
|
||||
CreditRecordAction.HOLD.value: "历史预扣占用",
|
||||
CreditRecordAction.HOLD_RELEASE.value: "历史预扣释放",
|
||||
}
|
||||
|
||||
CREDIT_RECORD_TYPE_LABELS = {
|
||||
|
||||
@@ -12,12 +12,12 @@ class LlmBillingLedgerState(StrEnum):
|
||||
INVALID = "invalid"
|
||||
BILLING_BYPASSED = "invalid" # 历史兼容;新系统不允许绕过计费
|
||||
RELEASED = "refunded" # 历史兼容别名
|
||||
CHARGED = "succeeded" # 历史兼容别名
|
||||
|
||||
CHARGED = "active" # 当前 attempt 已完成真实消费,可继续执行
|
||||
|
||||
|
||||
class LlmBillingExecutionStatus(StrEnum):
|
||||
PRE_DEDUCTED = "pre_deducted"
|
||||
CHARGED = "charged"
|
||||
PRE_DEDUCTED = "pre_deducted" # 历史兼容;新数据不得再创建
|
||||
PROCESSING = "processing"
|
||||
SUCCEEDED = "succeeded"
|
||||
FINAL_FAILED = "final_failed"
|
||||
@@ -34,10 +34,11 @@ class LlmCallAttemptStatus(StrEnum):
|
||||
|
||||
|
||||
class LlmBillingEvent(StrEnum):
|
||||
PRE_DEDUCT_START = "LLM_PRE_DEDUCT_START"
|
||||
PRE_DEDUCT_SUCCESS = "LLM_PRE_DEDUCT_SUCCESS"
|
||||
PRE_DEDUCT_INSUFFICIENT = "LLM_PRE_DEDUCT_INSUFFICIENT"
|
||||
PRE_DEDUCT_CONFIG_INVALID = "LLM_PRE_DEDUCT_CONFIG_INVALID"
|
||||
CHARGE_START = "LLM_CHARGE_START"
|
||||
CHARGE_IDEMPOTENT_HIT = "LLM_CHARGE_IDEMPOTENT_HIT"
|
||||
CHARGE_SUCCESS = "LLM_CHARGE_SUCCESS"
|
||||
CHARGE_INSUFFICIENT = "LLM_CHARGE_INSUFFICIENT"
|
||||
CHARGE_CONFIG_INVALID = "LLM_CHARGE_CONFIG_INVALID"
|
||||
EXECUTION_VALIDATE_START = "LLM_EXECUTION_VALIDATE_START"
|
||||
EXECUTION_VALIDATE_SUCCESS = "LLM_EXECUTION_VALIDATE_SUCCESS"
|
||||
EXECUTION_BLOCKED = "LLM_EXECUTION_BLOCKED"
|
||||
|
||||
@@ -405,7 +405,7 @@ async def _seed_data():
|
||||
("/generation-ai", "创作记录", "BulbOutlined", 3, None),
|
||||
("/generation-records", "项目记录", "VideoCameraOutlined", 3, None),
|
||||
("/credit-products", "积分产品", "GiftOutlined", 4, None),
|
||||
("/llm-billing-policies", "LLM预扣配置", "RobotOutlined", 5, "模型设置"),
|
||||
("/llm-billing-policies", "LLM积分配置", "RobotOutlined", 5, "模型设置"),
|
||||
("/llm-billing-executions", "LLM调用审计", "DatabaseOutlined", 6, "模型设置"),
|
||||
("/notifications", "消息推送", "BellOutlined", 5, None),
|
||||
("/payment-stats", "支付统计", "LineChartOutlined", 6, None),
|
||||
|
||||
@@ -8,13 +8,13 @@ from pydantic import BaseModel, Field
|
||||
class LlmBillingPolicyCreate(BaseModel):
|
||||
scene_code: str = Field(..., min_length=1, max_length=64)
|
||||
scene_name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
pre_deduct_credits: float = Field(..., ge=0, le=999999999.99, multiple_of=0.01)
|
||||
charge_credits: float = Field(..., gt=0, le=999999999.99, multiple_of=0.01)
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class LlmBillingPolicyUpdate(BaseModel):
|
||||
scene_name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
pre_deduct_credits: float | None = Field(default=None, ge=0, le=999999999.99, multiple_of=0.01)
|
||||
charge_credits: float | None = Field(default=None, gt=0, le=999999999.99, multiple_of=0.01)
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ class LlmBillingPolicyOut(BaseModel):
|
||||
id: str
|
||||
scene_code: str
|
||||
scene_name: str
|
||||
pre_deduct_credits: float
|
||||
charge_credits: float
|
||||
is_active: bool
|
||||
version: int
|
||||
created_by: str | None = None
|
||||
@@ -30,8 +30,6 @@ class LlmBillingPolicyOut(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class LlmCallAttemptOut(BaseModel):
|
||||
id: str
|
||||
@@ -70,7 +68,7 @@ class LlmBillingExecutionOut(BaseModel):
|
||||
model_name_snapshot: str | None = None
|
||||
provider_snapshot: str | None = None
|
||||
request_time: datetime
|
||||
pre_deduct_credits: float
|
||||
charge_credits: float
|
||||
status: str
|
||||
total_call_count: int
|
||||
successful_call_count: int
|
||||
@@ -84,5 +82,3 @@ class LlmBillingExecutionOut(BaseModel):
|
||||
completed_at: datetime | None = None
|
||||
refunded_at: datetime | None = None
|
||||
calls: list[LlmCallAttemptOut] = Field(default_factory=list)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -6,6 +6,11 @@ from typing import Any
|
||||
from sqlalchemy import and_, case, distinct, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.credit_balance import (
|
||||
CREDIT_ALLOCATION_ACTION_LABELS,
|
||||
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
|
||||
CREDIT_LEVEL_LABELS,
|
||||
)
|
||||
from app.enums.credit_record import (
|
||||
CREDIT_RECORD_ACTION_LABELS,
|
||||
CREDIT_RECORD_BILLING_SCENE_LABELS,
|
||||
@@ -129,7 +134,23 @@ def _build_filters(
|
||||
if charge_kind:
|
||||
filters.append(CreditRecord.charge_kind == charge_kind)
|
||||
if charge_action:
|
||||
filters.append(CreditRecord.charge_action == charge_action)
|
||||
if charge_action == "charge":
|
||||
# 历史 LLM Billing 已经真实减余额但元数据曾写成 pre_deduct,查询时统一按真实消费兼容。
|
||||
filters.append(or_(
|
||||
CreditRecord.charge_action == "charge",
|
||||
and_(
|
||||
CreditRecord.charge_action == "pre_deduct",
|
||||
CreditRecord.llm_billing_execution_id.is_not(None),
|
||||
),
|
||||
))
|
||||
elif charge_action == "pre_deduct":
|
||||
# 只展示真正的历史固定预扣,不把历史 LLM 真实消费混入。
|
||||
filters.append(and_(
|
||||
CreditRecord.charge_action == "pre_deduct",
|
||||
CreditRecord.llm_billing_execution_id.is_(None),
|
||||
))
|
||||
else:
|
||||
filters.append(CreditRecord.charge_action == charge_action)
|
||||
if source_module:
|
||||
filters.append(CreditRecord.source_module == source_module)
|
||||
if source_step_code:
|
||||
@@ -172,6 +193,25 @@ async def _load_deleted_map(db: AsyncSession, records: list[CreditRecord]) -> di
|
||||
return deleted_map
|
||||
|
||||
|
||||
def _is_legacy_llm_charge(record: CreditRecord) -> bool:
|
||||
return bool(
|
||||
record.llm_billing_execution_id
|
||||
and record.charge_action == "pre_deduct"
|
||||
and record.type == "consume"
|
||||
)
|
||||
|
||||
|
||||
def _normalized_charge_action(record: CreditRecord) -> str | None:
|
||||
return "charge" if _is_legacy_llm_charge(record) else record.charge_action
|
||||
|
||||
|
||||
def _normalized_description(record: CreditRecord) -> str | None:
|
||||
description = record.description
|
||||
if _is_legacy_llm_charge(record) and description:
|
||||
return description.replace("固定预扣积分", "积分消费").replace("固定预扣", "积分消费")
|
||||
return description
|
||||
|
||||
|
||||
def _record_to_item(
|
||||
record: CreditRecord,
|
||||
user: User | None,
|
||||
@@ -185,6 +225,7 @@ def _record_to_item(
|
||||
|
||||
user_type = record.user_type_snapshot or (user.user_type if user else None)
|
||||
frontend_kind = record.frontend_user_kind_snapshot or (getattr(user, "frontend_user_kind", None) if user else None)
|
||||
charge_action = _normalized_charge_action(record)
|
||||
return {
|
||||
"id": record.id,
|
||||
"user_id": record.user_id,
|
||||
@@ -204,7 +245,7 @@ def _record_to_item(
|
||||
"balance_delta": _round2(record.balance_delta),
|
||||
"expired_amount": _round2(record.expired_amount),
|
||||
"balance_after": _round2(record.balance_after),
|
||||
"description": record.description,
|
||||
"description": _normalized_description(record),
|
||||
"related_id": record.related_id,
|
||||
"biz_key": record.biz_key,
|
||||
"refund_for_biz_key": record.refund_for_biz_key,
|
||||
@@ -215,8 +256,8 @@ def _record_to_item(
|
||||
"attempt_no": record.attempt_no,
|
||||
"charge_kind": record.charge_kind,
|
||||
"charge_kind_label": _label(CREDIT_RECORD_CHARGE_KIND_LABELS, record.charge_kind),
|
||||
"charge_action": record.charge_action,
|
||||
"charge_action_label": _label(CREDIT_RECORD_ACTION_LABELS, record.charge_action),
|
||||
"charge_action": charge_action,
|
||||
"charge_action_label": _label(CREDIT_RECORD_ACTION_LABELS, charge_action),
|
||||
"credit_subject": record.credit_subject,
|
||||
"credit_subject_label": _label(CREDIT_RECORD_SUBJECT_LABELS, record.credit_subject),
|
||||
"media_type": record.media_type,
|
||||
@@ -321,9 +362,12 @@ async def list_admin_credit_records(
|
||||
"credit_balance_id": allocation.credit_balance_id,
|
||||
"source_allocation_id": allocation.source_allocation_id,
|
||||
"allocation_action": allocation.allocation_action,
|
||||
"allocation_action_label": _label(CREDIT_ALLOCATION_ACTION_LABELS, allocation.allocation_action),
|
||||
"amount": _round2(allocation.amount),
|
||||
"credit_level": allocation.credit_level_snapshot,
|
||||
"credit_level_label": _label(CREDIT_LEVEL_LABELS, allocation.credit_level_snapshot),
|
||||
"source_type": allocation.source_type_snapshot,
|
||||
"source_type_label": _label(CREDIT_BALANCE_SOURCE_TYPE_LABELS, allocation.source_type_snapshot),
|
||||
"source_id": allocation.source_id_snapshot,
|
||||
"valid_from": _iso(allocation.valid_from_snapshot),
|
||||
"expires_at": _iso(allocation.expires_at_snapshot),
|
||||
@@ -334,61 +378,39 @@ async def list_admin_credit_records(
|
||||
})
|
||||
items = [_record_to_item(record, user, deleted_map, allocation_map) for record, user in rows]
|
||||
|
||||
# 说明:
|
||||
# - consume 类型:amount 是负数(扣减积分),统计用 abs() 保证为正值
|
||||
# team_internal(团队内部积分流转/管理员分配)不参与消费/扣费统计——它不是真实消费
|
||||
# - refund 类型:amount 是正数(退回积分),为兼容旧数据/边缘场景也用 abs() 保证统计值恒正
|
||||
# 子分类:真实退款 refund(action='refund'/NULL) + 预扣释放 hold_release(action='hold_release')
|
||||
# - recharge 类型:amount 是正数(充值增加),金额直接求和,不需要 abs
|
||||
#
|
||||
# 口径更新(Bug 修复 · 第二次修正):
|
||||
# 1. 消费类统计仅看 type=consume(排除 team_internal 团队内部转账)
|
||||
# 2. 真实扣费 / 预扣占用 / 真实退款 / 预扣释放 全部改为"独立统计列",不再用差值推导
|
||||
# (避免任何一类范围不同导致推导失真)
|
||||
#
|
||||
# 消费类(type=consume):
|
||||
# - total_charge :真实扣费 charge_action in (NULL, 'charge') abs 求和
|
||||
# - total_hold :预扣占用 charge_action = 'hold' abs 求和
|
||||
# - total_consume :total_charge + total_hold = charge_action in (NULL, charge, hold) abs 求和
|
||||
# 回退类(type=refund):
|
||||
# - total_refund_real :真实退款 charge_action in (NULL, 'refund') abs 求和
|
||||
# - total_hold_release :预扣释放 charge_action = 'hold_release' abs 求和
|
||||
# - total_refund :total_refund_real + total_hold_release = type=refund 全部 abs 求和
|
||||
# 净消耗 net_consume = max(total_consume - total_refund, 0)
|
||||
#
|
||||
# 按积分 subject 分类的子项(图片/视频/提词/分析)仍保持「仅真实扣费 charge」口径不变:
|
||||
# 预扣是按任务预估的冻结,不是按图/视频实际产出,会让子分类统计失真。
|
||||
# 当前主口径只统计真实消费/真实退款;hold/hold_release 仅保留历史兼容字段,不再并入总消费/总退款。
|
||||
# 历史 LLM Billing 的 consume + pre_deduct + llm_billing_execution_id 本质已经真实减余额,
|
||||
# 查询统计时按 charge 兼容,但不批量改写历史数据库。
|
||||
_legacy_llm_charge_action = and_(
|
||||
CreditRecord.charge_action == "pre_deduct",
|
||||
CreditRecord.llm_billing_execution_id.is_not(None),
|
||||
)
|
||||
_real_charge_action = or_(
|
||||
CreditRecord.charge_action.is_(None),
|
||||
CreditRecord.charge_action == "charge",
|
||||
)
|
||||
_charge_or_hold_action = or_(
|
||||
CreditRecord.charge_action.is_(None),
|
||||
CreditRecord.charge_action == "charge",
|
||||
CreditRecord.charge_action == "hold",
|
||||
_legacy_llm_charge_action,
|
||||
)
|
||||
_real_refund_action = or_(
|
||||
CreditRecord.charge_action.is_(None),
|
||||
CreditRecord.charge_action == "refund",
|
||||
)
|
||||
# 仅统计 type=consume 的消费类(排除 team_internal 团队内部转账)
|
||||
_consume_type = CreditRecord.type == "consume"
|
||||
# 预扣释放 / 真实退款 filter(都是 type=refund,账本 L256 强校验 hold_release.type=refund)
|
||||
_refund_type = CreditRecord.type == "refund"
|
||||
_hold_release_filter = and_(
|
||||
CreditRecord.type == "refund",
|
||||
_refund_type,
|
||||
CreditRecord.charge_action == "hold_release",
|
||||
)
|
||||
_refund_type = CreditRecord.type == "refund"
|
||||
|
||||
summary_query = select(
|
||||
# 0: 充值
|
||||
func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0),
|
||||
# 1: 总消费 = total_charge + total_hold(真实扣费 + 预扣占用)
|
||||
func.coalesce(func.sum(case((and_(_consume_type, _charge_or_hold_action), func.abs(CreditRecord.amount)), else_=0)), 0),
|
||||
# 2: 总回退 = 真实退款 + 预扣释放(type=refund 全部流水)
|
||||
func.coalesce(func.sum(case((_refund_type, func.abs(CreditRecord.amount)), else_=0)), 0),
|
||||
# 1: 总真实消费
|
||||
func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
|
||||
# 2: 总真实退款
|
||||
func.coalesce(func.sum(case((and_(_refund_type, _real_refund_action), func.abs(CreditRecord.amount)), else_=0)), 0),
|
||||
# 3: 交易笔数
|
||||
func.count(CreditRecord.id),
|
||||
# 4-11: 生成条数 / 尝试次数 / 图片视频条数 / 图片视频提词分析消费(仍按 charge 口径)
|
||||
# 4-11: 生成统计与按 subject 的真实消费
|
||||
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, _consume_type, _real_charge_action), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
|
||||
func.count(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, _consume_type, _real_charge_action), 1), else_=None)),
|
||||
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "image", _consume_type, _real_charge_action), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
|
||||
@@ -397,17 +419,17 @@ async def list_admin_credit_records(
|
||||
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "video", _consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
|
||||
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.TEXT.value, _consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
|
||||
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.ANALYSIS.value, _consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
|
||||
# 12-14: Token
|
||||
func.coalesce(func.sum(CreditRecord.total_tokens), 0),
|
||||
func.coalesce(func.sum(CreditRecord.input_tokens), 0),
|
||||
func.coalesce(func.sum(CreditRecord.output_tokens), 0),
|
||||
# 15: 真实扣费 total_charge(独立列:type=consume AND charge_action in (NULL, 'charge'))
|
||||
# 12-14: Token 仅统计真实 LLM/媒体消费流水,避免退款/历史释放重复累计。
|
||||
func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), CreditRecord.total_tokens), else_=0)), 0),
|
||||
func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), CreditRecord.input_tokens), else_=0)), 0),
|
||||
func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), CreditRecord.output_tokens), else_=0)), 0),
|
||||
# 15: total_charge 与 total_consume 同口径,保留字段供旧前端兼容。
|
||||
func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
|
||||
# 16: 预扣占用 total_hold(独立列:type=consume AND charge_action='hold')
|
||||
# 16: 历史 hold 独立统计,不计入总消费。
|
||||
func.coalesce(func.sum(case((and_(_consume_type, CreditRecord.charge_action == "hold"), func.abs(CreditRecord.amount)), else_=0)), 0),
|
||||
# 17: 真实退款 total_refund_real(独立列:type=refund AND charge_action in (NULL, 'refund'))
|
||||
# 17: 真实退款,与 total_refund 同口径。
|
||||
func.coalesce(func.sum(case((and_(_refund_type, _real_refund_action), func.abs(CreditRecord.amount)), else_=0)), 0),
|
||||
# 18: 预扣释放 total_hold_release(独立列:type=refund AND charge_action='hold_release')
|
||||
# 18: 历史 hold_release 独立统计,不计入总退款。
|
||||
func.coalesce(func.sum(case((_hold_release_filter, func.abs(CreditRecord.amount)), else_=0)), 0),
|
||||
).select_from(CreditRecord).join(User, CreditRecord.user_id == User.id, isouter=True)
|
||||
if where_clause is not None:
|
||||
|
||||
@@ -10,11 +10,6 @@ from app.models.image_engine import ImageEngine
|
||||
from app.models.credit_ratio import CreditRatio
|
||||
|
||||
|
||||
async def calc_text_credits(db: AsyncSession, input_tokens: int, output_tokens: int) -> float:
|
||||
"""历史兼容函数:LLM 已按业务场景固定预扣,Token 不再折算用户积分。"""
|
||||
return 0.0
|
||||
|
||||
|
||||
async def _get_credit_ratio(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any, Mapping
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -11,34 +11,20 @@ from app.enums.credit_record import CreditRecordChargeKind, CreditRecordOwnerTyp
|
||||
from app.models.credit_record import CreditRecord
|
||||
from app.models.generation_record import GenerationRecord
|
||||
from app.services.generation.media_reference_service import calculate_media_reference_usage
|
||||
from app.models.module_generation_step import ModuleGenerationStep
|
||||
from app.models.token_usage import TokenUsage
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.services.credit_record_meta_service import (
|
||||
CreditRecordMeta,
|
||||
build_generation_media_meta,
|
||||
build_generation_record_prompt_meta,
|
||||
build_module_step_prompt_meta,
|
||||
build_shot_video_analysis_meta,
|
||||
)
|
||||
from app.services.credits import calc_image_credits, calc_text_credits, calc_video_credits, deduct_credits_result
|
||||
from app.utils.id_gen import generate_id
|
||||
from app.services.credits import calc_image_credits, calc_video_credits, deduct_credits_result
|
||||
|
||||
|
||||
CHARGE_TEXT_PROMPT = CreditRecordChargeKind.TEXT_PROMPT.value
|
||||
CHARGE_FILE_PARSE = CreditRecordChargeKind.FILE_PARSE.value
|
||||
CHARGE_VISION_INPUT = CreditRecordChargeKind.VISION_INPUT.value
|
||||
CHARGE_MEDIA = CreditRecordChargeKind.MEDIA.value
|
||||
CHARGE_VIDEO_ANALYSIS = CreditRecordChargeKind.VIDEO_ANALYSIS.value
|
||||
|
||||
OWNER_GENERATION_RECORD = CreditRecordOwnerType.GENERATION_RECORD.value
|
||||
OWNER_CHAT_GENERATION_TASK = CreditRecordOwnerType.CHAT_GENERATION_TASK.value
|
||||
OWNER_MODULE_GENERATION_STEP = CreditRecordOwnerType.MODULE_GENERATION_STEP.value
|
||||
OWNER_SHOT_REPLICATE_TASK_SET = CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.value
|
||||
OWNER_SHOT_REPLICATE_SEGMENT = CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value
|
||||
|
||||
_BIZ_KEY_PATTERN = re.compile(
|
||||
r"^(?P<owner_type>[^:]+):(?P<owner_id>[^:]+):attempt:(?P<attempt_no>\d+):(?P<charge_kind>[^:]+):(?P<action>charge|refund|pre_deduct|hold|hold_release)$"
|
||||
r"^(?P<owner_type>[^:]+):(?P<owner_id>[^:]+):attempt:(?P<attempt_no>\d+):(?P<charge_kind>[^:]+):(?P<action>charge|llm_charge|refund|pre_deduct|hold|hold_release)$"
|
||||
)
|
||||
|
||||
|
||||
@@ -76,15 +62,6 @@ def _round2(value: float | int | None) -> float:
|
||||
return round(float(value or 0), 2)
|
||||
|
||||
|
||||
def _safe_int(value: Any, default: int = 0) -> int:
|
||||
try:
|
||||
if value is None:
|
||||
return default
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def build_credit_biz_key(
|
||||
*,
|
||||
owner_type: str,
|
||||
@@ -101,8 +78,8 @@ def build_credit_biz_key(
|
||||
owner_id = owner_id.strip()
|
||||
charge_kind = charge_kind.strip()
|
||||
action = action.strip()
|
||||
if action not in ("charge", "refund", "pre_deduct", "hold", "hold_release"):
|
||||
raise ValueError("action 仅支持 charge/refund/pre_deduct/hold/hold_release")
|
||||
if action not in ("charge", "llm_charge", "refund", "pre_deduct", "hold", "hold_release"):
|
||||
raise ValueError("action 仅支持 charge/llm_charge/refund/pre_deduct/hold/hold_release")
|
||||
if attempt_no <= 0:
|
||||
raise ValueError("attempt_no 必须大于 0")
|
||||
return f"{owner_type}:{owner_id}:attempt:{attempt_no}:{charge_kind}:{action}"
|
||||
@@ -119,27 +96,6 @@ def parse_credit_biz_key(biz_key: str | None) -> dict[str, Any] | None:
|
||||
return data
|
||||
|
||||
|
||||
async def _get_config_float_or_none(db: AsyncSession, key: str) -> float | None:
|
||||
result = await db.execute(select(SystemConfig).where(SystemConfig.key == key).limit(1))
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
return None
|
||||
try:
|
||||
return float(config.value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _calc_optional_token_credits(db: AsyncSession, tokens: int, config_key: str) -> float:
|
||||
tokens = _safe_int(tokens)
|
||||
if tokens <= 0:
|
||||
return 0.0
|
||||
rate = await _get_config_float_or_none(db, config_key)
|
||||
if rate is None:
|
||||
return 0.0
|
||||
return round(tokens * rate / 1000, 2)
|
||||
|
||||
|
||||
async def get_next_credit_attempt_no(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -215,271 +171,6 @@ async def deduct_credits_locked_once(
|
||||
)
|
||||
|
||||
|
||||
async def charge_chatapi_prompt_usage(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
record: GenerationRecord,
|
||||
usage: Mapping[str, Any],
|
||||
project_name: str | None = None,
|
||||
) -> BillingSummary:
|
||||
"""项目记录提示词整理扣费;不参与生成失败媒体退款。"""
|
||||
project_name = project_name or "AI生成任务"
|
||||
items: list[BillingItem] = []
|
||||
|
||||
attempt_no = 1
|
||||
owner_type = OWNER_GENERATION_RECORD
|
||||
owner_id = record.id
|
||||
|
||||
input_tokens = _safe_int(usage.get("input_tokens"))
|
||||
output_tokens = _safe_int(usage.get("output_tokens"))
|
||||
text_credits = await calc_text_credits(db, input_tokens, output_tokens)
|
||||
text_meta = await build_generation_record_prompt_meta(
|
||||
db,
|
||||
record_id=record.id,
|
||||
attempt_no=attempt_no,
|
||||
charge_kind=CHARGE_TEXT_PROMPT,
|
||||
usage=usage,
|
||||
)
|
||||
items.append(
|
||||
await deduct_credits_locked_once(
|
||||
db,
|
||||
user_id=record.user_id,
|
||||
amount=text_credits,
|
||||
description=f"提示词优化 - {project_name}",
|
||||
related_id=record.id,
|
||||
charge_key=CHARGE_TEXT_PROMPT,
|
||||
biz_key=build_credit_biz_key(
|
||||
owner_type=owner_type,
|
||||
owner_id=owner_id,
|
||||
attempt_no=attempt_no,
|
||||
charge_kind=CHARGE_TEXT_PROMPT,
|
||||
action="charge",
|
||||
),
|
||||
attempt_no=attempt_no,
|
||||
record_meta=text_meta,
|
||||
)
|
||||
)
|
||||
|
||||
file_tokens = usage.get("file_parse_tokens") or usage.get("file_tokens") or usage.get("document_tokens") or 0
|
||||
file_parse_credits = await _calc_optional_token_credits(db, _safe_int(file_tokens), "file_parse_credits_per_1000_tokens")
|
||||
file_meta = await build_generation_record_prompt_meta(
|
||||
db,
|
||||
record_id=record.id,
|
||||
attempt_no=attempt_no,
|
||||
charge_kind=CHARGE_FILE_PARSE,
|
||||
usage={**dict(usage), "total_tokens": _safe_int(file_tokens), "input_tokens": _safe_int(file_tokens), "output_tokens": 0},
|
||||
)
|
||||
items.append(
|
||||
await deduct_credits_locked_once(
|
||||
db,
|
||||
user_id=record.user_id,
|
||||
amount=file_parse_credits,
|
||||
description="文件解析Token",
|
||||
related_id=record.id,
|
||||
charge_key=CHARGE_FILE_PARSE,
|
||||
biz_key=build_credit_biz_key(
|
||||
owner_type=owner_type,
|
||||
owner_id=owner_id,
|
||||
attempt_no=attempt_no,
|
||||
charge_kind=CHARGE_FILE_PARSE,
|
||||
action="charge",
|
||||
),
|
||||
attempt_no=attempt_no,
|
||||
record_meta=file_meta,
|
||||
)
|
||||
)
|
||||
|
||||
vision_tokens = usage.get("vision_input_tokens") or usage.get("image_input_tokens") or usage.get("image_tokens") or 0
|
||||
vision_input_credits = await _calc_optional_token_credits(db, _safe_int(vision_tokens), "vision_input_credits_per_1000_tokens")
|
||||
vision_meta = await build_generation_record_prompt_meta(
|
||||
db,
|
||||
record_id=record.id,
|
||||
attempt_no=attempt_no,
|
||||
charge_kind=CHARGE_VISION_INPUT,
|
||||
usage={**dict(usage), "total_tokens": _safe_int(vision_tokens), "input_tokens": _safe_int(vision_tokens), "output_tokens": 0},
|
||||
)
|
||||
items.append(
|
||||
await deduct_credits_locked_once(
|
||||
db,
|
||||
user_id=record.user_id,
|
||||
amount=vision_input_credits,
|
||||
description="图片理解Token",
|
||||
related_id=record.id,
|
||||
charge_key=CHARGE_VISION_INPUT,
|
||||
biz_key=build_credit_biz_key(
|
||||
owner_type=owner_type,
|
||||
owner_id=owner_id,
|
||||
attempt_no=attempt_no,
|
||||
charge_kind=CHARGE_VISION_INPUT,
|
||||
action="charge",
|
||||
),
|
||||
attempt_no=attempt_no,
|
||||
record_meta=vision_meta,
|
||||
)
|
||||
)
|
||||
|
||||
if hasattr(record, "text_credits_cost"):
|
||||
record.text_credits_cost = round(text_credits + file_parse_credits + vision_input_credits, 2)
|
||||
if hasattr(record, "text_tokens_used"):
|
||||
record.text_tokens_used = _safe_int(usage.get("total_tokens"), input_tokens + output_tokens)
|
||||
|
||||
return BillingSummary(record_id=record.id, user_id=record.user_id, items=items)
|
||||
|
||||
|
||||
async def charge_module_prompt_usage(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
step_id: str,
|
||||
usage: Mapping[str, Any],
|
||||
description: str,
|
||||
attempt_no: int = 1,
|
||||
) -> BillingSummary:
|
||||
"""爆款开头复刻/拆镜复刻模块图片/视频 AI 提词扣文本积分。
|
||||
|
||||
文本提词属于已经发生的 LLM 消费:
|
||||
- 调用成功后按 input_tokens + output_tokens 扣费。
|
||||
- 不参与后续图片/视频媒体生成失败退款。
|
||||
- 通过 module_generation_step:{step_id}:attempt:1:text_prompt:charge 幂等。
|
||||
"""
|
||||
input_tokens = _safe_int(usage.get("input_tokens"))
|
||||
output_tokens = _safe_int(usage.get("output_tokens"))
|
||||
text_credits = await calc_text_credits(db, input_tokens, output_tokens)
|
||||
biz_key = build_credit_biz_key(
|
||||
owner_type=OWNER_MODULE_GENERATION_STEP,
|
||||
owner_id=step_id,
|
||||
attempt_no=attempt_no,
|
||||
charge_kind=CHARGE_TEXT_PROMPT,
|
||||
action="charge",
|
||||
)
|
||||
record_meta = await build_module_step_prompt_meta(db, step_id=step_id, attempt_no=attempt_no, usage=usage)
|
||||
item = await deduct_credits_locked_once(
|
||||
db,
|
||||
user_id=user_id,
|
||||
amount=text_credits,
|
||||
description=description,
|
||||
related_id=step_id,
|
||||
charge_key=CHARGE_TEXT_PROMPT,
|
||||
biz_key=biz_key,
|
||||
attempt_no=attempt_no,
|
||||
record_meta=record_meta,
|
||||
)
|
||||
|
||||
result = await db.execute(select(ModuleGenerationStep).where(ModuleGenerationStep.id == step_id).limit(1))
|
||||
step = result.scalar_one_or_none()
|
||||
if step:
|
||||
step.token_usage_id = record_meta.token_usage_id
|
||||
step.model_config_id = usage.get("model_config_id")
|
||||
step.input_tokens = record_meta.input_tokens
|
||||
step.output_tokens = record_meta.output_tokens
|
||||
step.total_tokens = record_meta.total_tokens
|
||||
step.text_credits_cost = text_credits
|
||||
|
||||
return BillingSummary(record_id=step_id, user_id=user_id, items=[item])
|
||||
|
||||
|
||||
async def charge_shot_video_analysis_usage(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
owner_type: str,
|
||||
owner_id: str,
|
||||
usage: Mapping[str, Any],
|
||||
description: str,
|
||||
billing_scene: str,
|
||||
source_project_id: str | None = None,
|
||||
source_step_id: str | None = None,
|
||||
attempt_no: int | None = None,
|
||||
) -> BillingSummary:
|
||||
"""拆镜复刻视频分析扣分析积分。
|
||||
|
||||
视频分析属于“文字提示词 + 视频素材”的模型调用类消费,
|
||||
按 input_tokens + output_tokens 参考文本积分规则计费,
|
||||
但账务归类为 analysis/video_analysis,避免混入提词优化统计。
|
||||
"""
|
||||
attempt_no = attempt_no or await get_next_credit_attempt_no(
|
||||
db,
|
||||
owner_type=owner_type,
|
||||
owner_id=owner_id,
|
||||
charge_kind=CHARGE_VIDEO_ANALYSIS,
|
||||
)
|
||||
input_tokens = _safe_int(usage.get("input_tokens"))
|
||||
output_tokens = _safe_int(usage.get("output_tokens"))
|
||||
amount = await calc_text_credits(db, input_tokens, output_tokens)
|
||||
biz_key = build_credit_biz_key(
|
||||
owner_type=owner_type,
|
||||
owner_id=owner_id,
|
||||
attempt_no=attempt_no,
|
||||
charge_kind=CHARGE_VIDEO_ANALYSIS,
|
||||
action="charge",
|
||||
)
|
||||
usage_snapshot = dict(usage)
|
||||
token_usage_result = await db.execute(
|
||||
select(TokenUsage)
|
||||
.where(
|
||||
TokenUsage.owner_type == owner_type,
|
||||
TokenUsage.owner_id == owner_id,
|
||||
TokenUsage.biz_key == biz_key,
|
||||
)
|
||||
.order_by(TokenUsage.created_at.asc())
|
||||
.limit(1)
|
||||
)
|
||||
token_usage = token_usage_result.scalar_one_or_none()
|
||||
if token_usage is None:
|
||||
token_usage = TokenUsage(
|
||||
id=generate_id(),
|
||||
model_config_id=usage_snapshot.get("model_config_id"),
|
||||
user_id=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=owner_type,
|
||||
owner_id=owner_id,
|
||||
biz_key=biz_key,
|
||||
source_module=CreditRecordSourceModule.SHOT_REPLICATE.value,
|
||||
source_step_code="video_analysis",
|
||||
)
|
||||
db.add(token_usage)
|
||||
await db.flush()
|
||||
usage_snapshot["token_usage_id"] = token_usage.id
|
||||
record_meta = await build_shot_video_analysis_meta(
|
||||
db,
|
||||
owner_type=owner_type,
|
||||
owner_id=owner_id,
|
||||
attempt_no=attempt_no,
|
||||
usage=usage_snapshot,
|
||||
billing_scene=billing_scene,
|
||||
source_project_id=source_project_id,
|
||||
source_step_id=source_step_id,
|
||||
)
|
||||
item = await deduct_credits_locked_once(
|
||||
db,
|
||||
user_id=user_id,
|
||||
amount=amount,
|
||||
description=description,
|
||||
related_id=owner_id,
|
||||
charge_key=CHARGE_VIDEO_ANALYSIS,
|
||||
biz_key=biz_key,
|
||||
attempt_no=attempt_no,
|
||||
record_meta=record_meta,
|
||||
)
|
||||
|
||||
if record_meta.token_usage_id:
|
||||
result = await db.execute(select(TokenUsage).where(TokenUsage.id == record_meta.token_usage_id).limit(1))
|
||||
token_usage = result.scalar_one_or_none()
|
||||
if token_usage:
|
||||
token_usage.owner_type = token_usage.owner_type or owner_type
|
||||
token_usage.owner_id = token_usage.owner_id or owner_id
|
||||
token_usage.biz_key = token_usage.biz_key or biz_key
|
||||
token_usage.source_module = token_usage.source_module or CreditRecordSourceModule.SHOT_REPLICATE.value
|
||||
token_usage.source_step_code = token_usage.source_step_code or "video_analysis"
|
||||
|
||||
return BillingSummary(record_id=owner_id, user_id=user_id, items=[item])
|
||||
|
||||
|
||||
async def charge_generation_media_by_params(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
|
||||
@@ -55,7 +55,7 @@ from app.services.llm_billing import (
|
||||
log_provider_success,
|
||||
finalize_llm_business_failure,
|
||||
mark_business_success,
|
||||
pre_deduct,
|
||||
charge_llm_credits,
|
||||
)
|
||||
from app.services.operation_log_service import log_operation_error, log_operation_event
|
||||
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
|
||||
@@ -466,7 +466,7 @@ async def optimize_generation_prompt(
|
||||
|
||||
ctx = _billing_context(user_id=user_id, record_id=record_id, request_id=req.idempotency_key)
|
||||
try:
|
||||
await pre_deduct(db, ctx)
|
||||
await charge_llm_credits(db, ctx)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
@@ -638,7 +638,7 @@ async def optimize_generation_prompt(
|
||||
)
|
||||
# 供应商调用和 Token 已由独立调用审计事务保存。结果连续暂存失败后,
|
||||
# 当前 API 已没有可继续恢复的本地业务结果,必须先结束业务状态,再按
|
||||
# 原积分来源退回固定预扣,不能让账务永久停留在 processing。
|
||||
# 原积分来源退回场景积分消费,不能让账务永久停留在 processing。
|
||||
business_already_succeeded = False
|
||||
try:
|
||||
await db.rollback()
|
||||
@@ -672,7 +672,7 @@ async def optimize_generation_prompt(
|
||||
if business_already_succeeded:
|
||||
return PromptOptimizeServiceResult(record_id=record_id, idempotent=True)
|
||||
await finalize_llm_business_failure(ctx, error=str(failure))
|
||||
raise HTTPException(status_code=503, detail="提词已生成但本地暂存失败,固定预扣积分已按原来源退回")
|
||||
raise HTTPException(status_code=503, detail="提词已生成但本地暂存失败,场景消费积分已按原来源退回")
|
||||
|
||||
_log_event(
|
||||
GenerationRecordEventTypeEnum.PROMPT_OPTIMIZE_PROVIDER_RESULT_STAGED,
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.chat_generation_task import ChatGenerationTask
|
||||
from app.models.model_config import ModelConfig
|
||||
from app.models.token_usage import TokenUsage
|
||||
from app.services.generation.log_service import log_provider_call
|
||||
from app.services.provider_limit import provider_limit
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
|
||||
def _absolute_url(url: str) -> str:
|
||||
if url.startswith("http://") or url.startswith("https://") or url.startswith("data:"):
|
||||
return url
|
||||
base = settings.BASE_URL.rstrip("/")
|
||||
return f"{base}/{url.lstrip('/')}"
|
||||
|
||||
|
||||
def _load_refs(record: ChatGenerationTask) -> list[dict]:
|
||||
if not record.media_references:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(record.media_references)
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def _build_user_content(record: ChatGenerationTask, db: AsyncSession | None = None) -> list[dict[str, Any]]:
|
||||
from app.utils.media import get_llm_media_as_base64, media_to_base64
|
||||
|
||||
if record.gen_type == "image":
|
||||
params = f"图片参数:分辨率档位={record.image_size or '2K'},比例={record.image_proportion or '1:1'},像素={record.image_px or '2048x2048'}"
|
||||
else:
|
||||
params = f"视频参数:时长={record.duration or 4}秒,比例={record.aspect_ratio or '16:9'},分辨率={record.resolution or '480p'}"
|
||||
|
||||
text = (
|
||||
f"生成类型:{record.gen_type}\n"
|
||||
f"{params}\n"
|
||||
f"用户描述:{record.original_prompt}\n\n"
|
||||
"请只输出最终可直接用于图片/视频生成模型的 prompt,不要说你已经生成了图片或视频。"
|
||||
)
|
||||
parts: list[dict[str, Any]] = [{"type": "text", "text": text}]
|
||||
for ref in _load_refs(record):
|
||||
ref_type = ref.get("type")
|
||||
ref_url = ref.get("url") or ""
|
||||
if not ref_url:
|
||||
continue
|
||||
if db and await get_llm_media_as_base64(db):
|
||||
if ref_type == "image":
|
||||
url = await media_to_base64(ref_url, "image/png")
|
||||
elif ref_type == "video":
|
||||
url = await media_to_base64(ref_url, "video/mp4")
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
url = _absolute_url(ref_url)
|
||||
if ref_type == "image":
|
||||
parts.append({"type": "image_url", "image_url": {"url": url}})
|
||||
elif ref_type == "video":
|
||||
parts.append({"type": "video_url", "video_url": {"url": url}})
|
||||
return parts
|
||||
|
||||
|
||||
async def _get_model_config(db: AsyncSession) -> ModelConfig:
|
||||
result = await db.execute(
|
||||
select(ModelConfig)
|
||||
.where(ModelConfig.is_active == True, ModelConfig.deleted_at.is_(None))
|
||||
.order_by(ModelConfig.priority.desc())
|
||||
.limit(1)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if not config:
|
||||
raise ValueError("没有可用的ChatAPI模型配置")
|
||||
if config.provider == "mock":
|
||||
return config
|
||||
if not config.api_base or not config.api_key or not config.model_name:
|
||||
raise ValueError("ChatAPI模型配置不完整")
|
||||
return config
|
||||
|
||||
|
||||
async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask) -> tuple[str, dict]:
|
||||
"""Call ChatAPI once with current request params and attachments. No history context."""
|
||||
config_row = await _get_model_config(db)
|
||||
if config_row.provider == "mock":
|
||||
original_prompt = str(record.original_prompt or "")
|
||||
await db.commit()
|
||||
return original_prompt, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
||||
|
||||
user_content = await _build_user_content(record, db)
|
||||
config = SimpleNamespace(
|
||||
id=str(config_row.id),
|
||||
name=str(config_row.name or ""),
|
||||
provider=str(config_row.provider or ""),
|
||||
api_base=str(config_row.api_base or ""),
|
||||
api_key=str(config_row.api_key or ""),
|
||||
model_name=str(config_row.model_name or ""),
|
||||
max_tokens=config_row.max_tokens,
|
||||
temperature=config_row.temperature,
|
||||
)
|
||||
record = SimpleNamespace(
|
||||
id=str(record.id),
|
||||
user_id=str(record.user_id),
|
||||
engine_id=str(record.engine_id or "") or None,
|
||||
generation_mode=str(record.generation_mode or ""),
|
||||
generation_attempt_no=int(record.generation_attempt_no or 1),
|
||||
)
|
||||
# Release all configuration/media lookup reads before the remote request.
|
||||
await db.commit()
|
||||
|
||||
system_prompt = (
|
||||
"你是图片/视频生成提示词整理助手。你的职责是根据用户文字、上传图片/视频和生成参数,"
|
||||
"整理最终可直接用于生成模型的 prompt。不要声称你已经生成图片或视频,不要调用工具。"
|
||||
"输出中文为主,内容具体、可执行,保留用户关键要求。"
|
||||
)
|
||||
request_data = {
|
||||
"model": config.model_name,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
"max_tokens": config.max_tokens,
|
||||
"temperature": config.temperature,
|
||||
}
|
||||
started = time.perf_counter()
|
||||
call_id = await log_provider_call(
|
||||
record,
|
||||
provider=config.provider,
|
||||
api_type="chat_prompt",
|
||||
model=config.model_name,
|
||||
engine_id=record.engine_id,
|
||||
status="request",
|
||||
request_data=request_data,
|
||||
module="generation_record",
|
||||
step_code="prompt_optimize",
|
||||
)
|
||||
async with provider_limit("ark_chat_prompt", settings.ARK_CHAT_PROMPT_MAX_CONCURRENCY):
|
||||
async with httpx.AsyncClient(timeout=settings.CHATAPI_REQUEST_TIMEOUT_SECONDS) as client:
|
||||
response: httpx.Response | None = None
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{config.api_base.rstrip('/')}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {config.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=request_data,
|
||||
)
|
||||
latency_ms = int((time.perf_counter() - started) * 1000)
|
||||
if response.status_code >= 400:
|
||||
message = response.text[:5000]
|
||||
await log_provider_call(
|
||||
record,
|
||||
provider=config.provider,
|
||||
api_type="chat_prompt",
|
||||
model=config.model_name,
|
||||
engine_id=record.engine_id,
|
||||
status="failed",
|
||||
latency_ms=latency_ms,
|
||||
http_status=response.status_code,
|
||||
response_data=response.text,
|
||||
error_message=message,
|
||||
call_id=call_id,
|
||||
module="generation_record",
|
||||
step_code="prompt_optimize",
|
||||
)
|
||||
raise RuntimeError(f"ChatAPI HTTP {response.status_code}: {message}")
|
||||
data = response.json()
|
||||
except Exception as exc:
|
||||
if isinstance(exc, RuntimeError) and str(exc).startswith("ChatAPI HTTP "):
|
||||
raise
|
||||
latency_ms = int((time.perf_counter() - started) * 1000)
|
||||
await log_provider_call(
|
||||
record,
|
||||
provider=config.provider,
|
||||
api_type="chat_prompt",
|
||||
model=config.model_name,
|
||||
engine_id=record.engine_id,
|
||||
status="failed",
|
||||
latency_ms=latency_ms,
|
||||
http_status=response.status_code if response is not None else None,
|
||||
response_data=response.text if response is not None else None,
|
||||
error_message=str(exc),
|
||||
call_id=call_id,
|
||||
module="generation_record",
|
||||
step_code="prompt_optimize",
|
||||
)
|
||||
raise
|
||||
|
||||
usage = data.get("usage", {}) or {}
|
||||
input_tokens = int(usage.get("prompt_tokens", 0) or 0)
|
||||
output_tokens = int(usage.get("completion_tokens", 0) or 0)
|
||||
total_tokens = int(usage.get("total_tokens", input_tokens + output_tokens) or 0)
|
||||
await log_provider_call(
|
||||
record,
|
||||
provider=config.provider,
|
||||
api_type="chat_prompt",
|
||||
model=config.model_name,
|
||||
engine_id=record.engine_id,
|
||||
status="success",
|
||||
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||
http_status=response.status_code if response is not None else 200,
|
||||
response_data=data,
|
||||
prompt_tokens=input_tokens,
|
||||
completion_tokens=output_tokens,
|
||||
total_tokens=total_tokens,
|
||||
call_id=call_id,
|
||||
module="generation_record",
|
||||
step_code="prompt_optimize",
|
||||
)
|
||||
|
||||
content = data.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
|
||||
if not content:
|
||||
await log_provider_call(
|
||||
record,
|
||||
provider=config.provider,
|
||||
api_type="chat_prompt",
|
||||
model=config.model_name,
|
||||
engine_id=record.engine_id,
|
||||
status="failed",
|
||||
error_message="ChatAPI未返回有效prompt",
|
||||
call_id=call_id,
|
||||
module="generation_record",
|
||||
step_code="prompt_optimize",
|
||||
)
|
||||
raise RuntimeError("ChatAPI未返回有效prompt")
|
||||
|
||||
token_usage_id = generate_id()
|
||||
try:
|
||||
db.add(TokenUsage(
|
||||
id=token_usage_id,
|
||||
model_config_id=config.id,
|
||||
user_id=record.user_id,
|
||||
owner_type="generation_record",
|
||||
owner_id=record.id,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
total_tokens=total_tokens,
|
||||
))
|
||||
await db.flush()
|
||||
except Exception as exc:
|
||||
await log_provider_call(
|
||||
record,
|
||||
provider=config.provider,
|
||||
api_type="chat_prompt",
|
||||
model=config.model_name,
|
||||
engine_id=record.engine_id,
|
||||
status="failed",
|
||||
error_message=f"token usage写入失败: {exc}",
|
||||
call_id=call_id,
|
||||
module="generation_record",
|
||||
step_code="prompt_optimize",
|
||||
)
|
||||
raise
|
||||
return content, {
|
||||
"token_usage_id": token_usage_id,
|
||||
"model_config_id": config.id,
|
||||
"model_config_name": config.name,
|
||||
"model_provider": config.provider,
|
||||
"model_name": config.model_name,
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
}
|
||||
@@ -54,7 +54,7 @@ from app.services.module_generation_log_service import log_module_error, log_mod
|
||||
from app.services.llm import optimize_prompt
|
||||
from app.services.llm_billing import (
|
||||
LlmBillingContext,
|
||||
ensure_pre_deducted,
|
||||
ensure_llm_charged,
|
||||
log_provider_failure,
|
||||
record_provider_exception,
|
||||
log_provider_start,
|
||||
@@ -62,7 +62,7 @@ from app.services.llm_billing import (
|
||||
refund_on_final_failure,
|
||||
finalize_llm_business_failure,
|
||||
mark_business_success,
|
||||
pre_deduct,
|
||||
charge_llm_credits,
|
||||
)
|
||||
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,
|
||||
@@ -893,7 +893,7 @@ async def submit_image_prompt_optimize(
|
||||
project.status = ModuleProjectStatusEnum.PROCESSING.value
|
||||
project.current_step_code = HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
|
||||
project.error_message = None
|
||||
await pre_deduct(
|
||||
await charge_llm_credits(
|
||||
db,
|
||||
LlmBillingContext(
|
||||
user_id=str(project.user_id),
|
||||
@@ -1008,10 +1008,10 @@ async def run_image_prompt_optimize(
|
||||
description_prefix="爆款开头复刻图片AI提词优化",
|
||||
trace_id=f"hot-opening-image-prompt:{step_id_value}",
|
||||
)
|
||||
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
|
||||
if not pre_deduct_validation.can_execute:
|
||||
charge_validation = await ensure_llm_charged(db, llm_billing_context)
|
||||
if not charge_validation.can_execute:
|
||||
step.status = ModuleStepStatusEnum.FAILED.value
|
||||
step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务"
|
||||
step.error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止任务"
|
||||
step.completed_at = _now()
|
||||
project.status = ModuleProjectStatusEnum.FAILED.value
|
||||
project.error_message = step.error_message
|
||||
@@ -1327,7 +1327,7 @@ async def submit_video_prompt_optimize(
|
||||
project.status = ModuleProjectStatusEnum.PROCESSING.value
|
||||
project.current_step_code = HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value
|
||||
project.error_message = None
|
||||
await pre_deduct(
|
||||
await charge_llm_credits(
|
||||
db,
|
||||
LlmBillingContext(
|
||||
user_id=str(project.user_id),
|
||||
@@ -1440,10 +1440,10 @@ async def run_video_prompt_optimize(
|
||||
description_prefix="爆款开头复刻视频AI提词优化",
|
||||
trace_id=f"hot-opening-video-prompt:{step_id_value}",
|
||||
)
|
||||
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
|
||||
if not pre_deduct_validation.can_execute:
|
||||
charge_validation = await ensure_llm_charged(db, llm_billing_context)
|
||||
if not charge_validation.can_execute:
|
||||
step.status = ModuleStepStatusEnum.FAILED.value
|
||||
step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务"
|
||||
step.error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止任务"
|
||||
step.completed_at = _now()
|
||||
project.status = ModuleProjectStatusEnum.FAILED.value
|
||||
project.error_message = step.error_message
|
||||
|
||||
@@ -3,24 +3,24 @@ from app.services.llm_billing.context import (
|
||||
LlmBillingContext,
|
||||
LlmBillingPolicy,
|
||||
LlmBillingStateError,
|
||||
LlmPreDeductResult,
|
||||
LlmPreDeductValidation,
|
||||
LlmChargeResult,
|
||||
LlmChargeValidation,
|
||||
)
|
||||
from app.services.llm_billing.service import (
|
||||
ensure_pre_deducted,
|
||||
charge_llm_credits,
|
||||
ensure_llm_charged,
|
||||
finalize_llm_business_failure,
|
||||
get_llm_ledger_states,
|
||||
log_celery_dispatch_compensated,
|
||||
log_celery_dispatch_failure,
|
||||
log_celery_dispatch_start,
|
||||
log_celery_dispatch_success,
|
||||
log_provider_failure,
|
||||
record_provider_exception,
|
||||
log_provider_start,
|
||||
log_provider_success,
|
||||
refund_on_final_failure,
|
||||
finalize_llm_business_failure,
|
||||
mark_business_success,
|
||||
pre_deduct,
|
||||
record_provider_exception,
|
||||
refund_on_final_failure,
|
||||
validate_retryable_previous_attempt,
|
||||
)
|
||||
|
||||
@@ -29,10 +29,10 @@ __all__ = [
|
||||
"LlmBillingContext",
|
||||
"LlmBillingPolicy",
|
||||
"LlmBillingStateError",
|
||||
"LlmPreDeductResult",
|
||||
"LlmPreDeductValidation",
|
||||
"pre_deduct",
|
||||
"ensure_pre_deducted",
|
||||
"LlmChargeResult",
|
||||
"LlmChargeValidation",
|
||||
"charge_llm_credits",
|
||||
"ensure_llm_charged",
|
||||
"get_llm_ledger_states",
|
||||
"validate_retryable_previous_attempt",
|
||||
"log_provider_start",
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.llm_billing import LlmBillingExecutionStatus, LlmCallAttemptStatus
|
||||
from app.enums.llm_billing import LlmBillingDomain, LlmBillingEvent, LlmBillingExecutionStatus, LlmCallAttemptStatus
|
||||
from app.models.base import AsyncSessionLocal
|
||||
from app.models.credit_record import CreditRecord
|
||||
from app.models.llm_billing.call_attempt import LlmCallAttempt
|
||||
@@ -14,9 +14,35 @@ from app.models.llm_billing.execution import LlmBillingExecution
|
||||
from app.models.token_usage import TokenUsage
|
||||
from app.services.credit.utils import utc_now
|
||||
from app.services.llm_billing.context import LlmBillingContext, LlmBillingStateError
|
||||
from app.services.operation_log_service import log_operation_event
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
|
||||
def _audit_log(ctx: LlmBillingContext, event: LlmBillingEvent, **detail: Any) -> None:
|
||||
log_operation_event(
|
||||
domain=LlmBillingDomain.LLM_BILLING.value,
|
||||
module=ctx.source_module or "llm",
|
||||
event_type=event.value,
|
||||
event_status="success",
|
||||
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=ctx.description_prefix or ctx.billing_scene or "LLM调用审计",
|
||||
detail={
|
||||
"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,
|
||||
"call_attempt_id": ctx.current_call_attempt_id,
|
||||
**detail,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _usage_int(usage: Mapping[str, Any] | None, *keys: str) -> int:
|
||||
for key in keys:
|
||||
value = (usage or {}).get(key)
|
||||
@@ -43,11 +69,12 @@ async def create_call_attempt(
|
||||
)
|
||||
execution = result.scalar_one_or_none()
|
||||
if execution is None:
|
||||
raise LlmBillingStateError("LLM固定预扣执行记录不存在,禁止调用模型")
|
||||
raise LlmBillingStateError("LLM积分消费执行记录不存在,禁止调用模型")
|
||||
if execution.model_config_id and ctx.model_config_id and execution.model_config_id != ctx.model_config_id:
|
||||
raise LlmBillingStateError("自动重试模型与首次选定模型不一致,已拦截降级/切换")
|
||||
if execution.status not in {
|
||||
LlmBillingExecutionStatus.PRE_DEDUCTED.value,
|
||||
LlmBillingExecutionStatus.CHARGED.value,
|
||||
LlmBillingExecutionStatus.PRE_DEDUCTED.value, # 历史兼容
|
||||
LlmBillingExecutionStatus.PROCESSING.value,
|
||||
}:
|
||||
raise LlmBillingStateError(f"LLM执行状态不允许调用模型:{execution.status}")
|
||||
@@ -90,7 +117,7 @@ async def _get_or_create_token_usage(
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
total_tokens: int,
|
||||
) -> TokenUsage:
|
||||
) -> tuple[TokenUsage, bool]:
|
||||
"""先持久化 TokenUsage,再允许任何外键引用它。
|
||||
|
||||
LlmCallAttempt 仅保存 token_usage_id 字符串,没有 ORM relationship。SQLAlchemy
|
||||
@@ -105,7 +132,7 @@ async def _get_or_create_token_usage(
|
||||
)
|
||||
existing = existing_result.scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing
|
||||
return existing, False
|
||||
|
||||
usage_row = TokenUsage(
|
||||
id=generate_id(),
|
||||
@@ -136,8 +163,8 @@ async def _get_or_create_token_usage(
|
||||
existing = existing_result.scalar_one_or_none()
|
||||
if existing is None:
|
||||
raise
|
||||
return existing
|
||||
return usage_row
|
||||
return existing, False
|
||||
return usage_row, True
|
||||
|
||||
|
||||
async def finish_call_success(
|
||||
@@ -167,7 +194,7 @@ async def finish_call_success(
|
||||
ctx.token_usage_id = attempt.token_usage_id
|
||||
return
|
||||
previous_status = attempt.status
|
||||
usage_row = await _get_or_create_token_usage(
|
||||
usage_row, token_usage_created = await _get_or_create_token_usage(
|
||||
db,
|
||||
ctx=ctx,
|
||||
attempt=attempt,
|
||||
@@ -222,8 +249,15 @@ async def finish_call_success(
|
||||
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
|
||||
record.token_usage_id = token_usage_id
|
||||
ctx.token_usage_id = token_usage_id
|
||||
_audit_log(
|
||||
ctx,
|
||||
LlmBillingEvent.TOKEN_USAGE_CREATED if token_usage_created else LlmBillingEvent.TOKEN_USAGE_REUSED,
|
||||
token_usage_id=token_usage_id,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
total_tokens=total_tokens,
|
||||
)
|
||||
|
||||
|
||||
async def finish_call_failure(ctx: LlmBillingContext, *, error: str, status: str = "failed") -> str:
|
||||
|
||||
@@ -12,11 +12,11 @@ async def get_llm_billing_policy(
|
||||
*,
|
||||
scene_code: str | None = None,
|
||||
) -> LlmBillingPolicy:
|
||||
"""按业务场景读取固定预扣;不再读取旧 SystemConfig 冻结配置。"""
|
||||
"""按业务场景读取固定消费积分;不再读取旧 SystemConfig 冻结/预扣配置。"""
|
||||
resolved_scene = scene_code
|
||||
if not resolved_scene:
|
||||
return LlmBillingPolicy(
|
||||
pre_deduct_credits=0.0,
|
||||
charge_credits=0.0,
|
||||
valid=False,
|
||||
error="LLM业务场景不能为空",
|
||||
)
|
||||
@@ -28,25 +28,25 @@ async def get_llm_billing_policy(
|
||||
model = result.scalar_one_or_none()
|
||||
if model is None:
|
||||
return LlmBillingPolicy(
|
||||
pre_deduct_credits=0.0,
|
||||
charge_credits=0.0,
|
||||
valid=False,
|
||||
error=f"未配置LLM场景固定预扣积分:{resolved_scene}",
|
||||
error=f"未配置LLM场景消费积分:{resolved_scene}",
|
||||
)
|
||||
# 数据库物理字段 pre_deduct_credits 为历史命名,本轮不迁移字段;业务语义统一为 charge_credits。
|
||||
amount = float(model.pre_deduct_credits)
|
||||
if not model.is_active or amount <= 0:
|
||||
return LlmBillingPolicy(
|
||||
pre_deduct_credits=amount,
|
||||
charge_credits=amount,
|
||||
valid=False,
|
||||
error=f"LLM场景固定预扣配置未启用或金额无效:{resolved_scene}",
|
||||
error=f"LLM场景积分配置未启用或金额无效:{resolved_scene}",
|
||||
policy_id=model.id,
|
||||
version=model.version,
|
||||
scene_name=model.scene_name,
|
||||
)
|
||||
return LlmBillingPolicy(
|
||||
pre_deduct_credits=round(amount, 2),
|
||||
charge_credits=round(amount, 2),
|
||||
valid=True,
|
||||
policy_id=model.id,
|
||||
version=model.version,
|
||||
scene_name=model.scene_name,
|
||||
)
|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@ from app.services.generation.billing_service import build_credit_biz_key
|
||||
|
||||
|
||||
class LlmBillingConfigurationError(RuntimeError):
|
||||
"""LLM 场景固定预扣配置无效。"""
|
||||
"""LLM 场景积分配置无效。"""
|
||||
|
||||
|
||||
class LlmBillingStateError(RuntimeError):
|
||||
"""业务 attempt 的固定预扣状态不允许继续执行。"""
|
||||
"""业务 attempt 的 LLM 积分消费状态不允许继续执行。"""
|
||||
|
||||
|
||||
class LlmProviderPostprocessError(RuntimeError):
|
||||
@@ -26,7 +26,7 @@ class LlmProviderPostprocessError(RuntimeError):
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class LlmBillingPolicy:
|
||||
pre_deduct_credits: float
|
||||
charge_credits: float
|
||||
valid: bool = True
|
||||
error: str | None = None
|
||||
policy_id: str | None = None
|
||||
@@ -65,13 +65,15 @@ class LlmBillingContext:
|
||||
current_call_attempt_id: str | None = None
|
||||
|
||||
@property
|
||||
def pre_deduct_biz_key(self) -> str:
|
||||
def charge_biz_key(self) -> str:
|
||||
return build_credit_biz_key(
|
||||
owner_type=self.owner_type,
|
||||
owner_id=self.owner_id,
|
||||
attempt_no=self.attempt_no,
|
||||
charge_kind=self.charge_kind,
|
||||
action="pre_deduct",
|
||||
# 使用独立 llm_charge 幂等键,避免与历史 Token 按量计费的 :charge 流水碰撞;
|
||||
# CreditRecord 的业务动作仍然记录为 charge(真实消费)。
|
||||
action="llm_charge",
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -86,11 +88,11 @@ class LlmBillingContext:
|
||||
|
||||
@property
|
||||
def billing_biz_key(self) -> str:
|
||||
return self.pre_deduct_biz_key
|
||||
return self.charge_biz_key
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class LlmPreDeductResult:
|
||||
class LlmChargeResult:
|
||||
amount: float
|
||||
state: LlmBillingLedgerState
|
||||
created: bool = False
|
||||
@@ -100,10 +102,10 @@ class LlmPreDeductResult:
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class LlmPreDeductValidation:
|
||||
class LlmChargeValidation:
|
||||
can_execute: bool
|
||||
amount: float
|
||||
state: LlmBillingLedgerState
|
||||
reason: str | None = None
|
||||
pre_deduct_record_id: str | None = None
|
||||
charge_record_id: str | None = None
|
||||
execution_id: str | None = None
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.llm_billing import LlmBillingExecutionStatus
|
||||
from app.models.llm_billing.call_attempt import LlmCallAttempt
|
||||
from app.models.llm_billing.execution import LlmBillingExecution
|
||||
|
||||
|
||||
def _normalized_execution_status(status: str) -> str:
|
||||
if status == LlmBillingExecutionStatus.PRE_DEDUCTED.value:
|
||||
return LlmBillingExecutionStatus.CHARGED.value
|
||||
return status
|
||||
|
||||
|
||||
def _execution_status_filter(status: str):
|
||||
# 新接口只暴露 charged;查询时同时兼容历史 pre_deducted 数据。
|
||||
if status in {
|
||||
LlmBillingExecutionStatus.CHARGED.value,
|
||||
LlmBillingExecutionStatus.PRE_DEDUCTED.value,
|
||||
}:
|
||||
return or_(
|
||||
LlmBillingExecution.status == LlmBillingExecutionStatus.CHARGED.value,
|
||||
LlmBillingExecution.status == LlmBillingExecutionStatus.PRE_DEDUCTED.value,
|
||||
)
|
||||
return LlmBillingExecution.status == status
|
||||
|
||||
|
||||
async def list_executions_with_calls(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -20,9 +40,10 @@ async def list_executions_with_calls(
|
||||
if scene_code:
|
||||
filters.append(LlmBillingExecution.scene_code == scene_code)
|
||||
if status:
|
||||
filters.append(LlmBillingExecution.status == status)
|
||||
filters.append(_execution_status_filter(status))
|
||||
if user_id:
|
||||
filters.append(LlmBillingExecution.user_id == user_id)
|
||||
|
||||
total_result = await db.execute(select(func.count(LlmBillingExecution.id)).where(*filters))
|
||||
total = int(total_result.scalar_one() or 0)
|
||||
result = await db.execute(
|
||||
@@ -33,22 +54,29 @@ async def list_executions_with_calls(
|
||||
.limit(page_size)
|
||||
)
|
||||
executions = list(result.scalars().all())
|
||||
ids = [item.id for item in executions]
|
||||
calls_by_execution: dict[str, list[LlmCallAttempt]] = {item_id: [] for item_id in ids}
|
||||
if ids:
|
||||
|
||||
# 先收集 execution ids,再一次批查所有 CallAttempt 并按 execution_id 回填,避免 N+1。
|
||||
execution_ids = [item.id for item in executions]
|
||||
calls_by_execution: dict[str, list[LlmCallAttempt]] = {item_id: [] for item_id in execution_ids}
|
||||
if execution_ids:
|
||||
call_result = await db.execute(
|
||||
select(LlmCallAttempt)
|
||||
.where(LlmCallAttempt.billing_execution_id.in_(ids))
|
||||
.where(LlmCallAttempt.billing_execution_id.in_(execution_ids))
|
||||
.order_by(LlmCallAttempt.billing_execution_id, LlmCallAttempt.call_sequence)
|
||||
)
|
||||
for call in call_result.scalars().all():
|
||||
calls_by_execution.setdefault(call.billing_execution_id, []).append(call)
|
||||
items = []
|
||||
|
||||
items: list[dict] = []
|
||||
for execution in executions:
|
||||
# 数据库物理字段 pre_deduct_credits 为历史命名;Admin/API 统一只暴露 charge_credits。
|
||||
data = {
|
||||
column.name: getattr(execution, column.name)
|
||||
for column in LlmBillingExecution.__table__.columns
|
||||
if column.name != "pre_deduct_credits"
|
||||
}
|
||||
data["charge_credits"] = execution.pre_deduct_credits
|
||||
data["status"] = _normalized_execution_status(execution.status)
|
||||
data["calls"] = calls_by_execution.get(execution.id, [])
|
||||
items.append(data)
|
||||
return items, total
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from dataclasses import replace
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy import select, tuple_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.credit_record import (
|
||||
CREDIT_RECORD_BILLING_SCENE_LABELS,
|
||||
CreditRecordAction,
|
||||
CreditRecordChargeKind,
|
||||
CreditRecordSubject,
|
||||
)
|
||||
from app.enums.llm_billing import (
|
||||
@@ -37,18 +38,19 @@ from app.services.llm_billing.context import (
|
||||
LlmBillingContext,
|
||||
LlmBillingStateError,
|
||||
LlmProviderPostprocessError,
|
||||
LlmPreDeductResult,
|
||||
LlmPreDeductValidation,
|
||||
LlmChargeResult,
|
||||
LlmChargeValidation,
|
||||
)
|
||||
from app.services.operation_log_service import log_operation_event
|
||||
from app.utils.exceptions import InsufficientCreditsError
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _ExecutionState:
|
||||
execution: LlmBillingExecution | None
|
||||
state: LlmBillingLedgerState
|
||||
reason: str | None = None
|
||||
|
||||
def _credit_subject(ctx: LlmBillingContext) -> str:
|
||||
if ctx.charge_kind == CreditRecordChargeKind.VIDEO_ANALYSIS.value:
|
||||
return CreditRecordSubject.ANALYSIS.value
|
||||
return CreditRecordSubject.TEXT.value
|
||||
|
||||
|
||||
def _scene_name(ctx: LlmBillingContext, policy_name: str | None = None) -> str:
|
||||
@@ -122,13 +124,21 @@ async def _select_fixed_model(db: AsyncSession, ctx: LlmBillingContext) -> None:
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
# CallAttempt/TokenUsage 使用独立短事务更新 execution 聚合值;主 Session
|
||||
# 可能在 commit 前后仍持有同一对象。强制回填数据库最新标量,避免
|
||||
# expire_on_commit=False 下 identity map 返回旧 Token/调用次数。
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
if for_update:
|
||||
stmt = stmt.with_for_update()
|
||||
result = await db.execute(stmt)
|
||||
@@ -139,6 +149,7 @@ def _state_for_execution(execution: LlmBillingExecution | None) -> LlmBillingLed
|
||||
if execution is None:
|
||||
return LlmBillingLedgerState.MISSING
|
||||
mapping = {
|
||||
LlmBillingExecutionStatus.CHARGED.value: LlmBillingLedgerState.ACTIVE,
|
||||
LlmBillingExecutionStatus.PRE_DEDUCTED.value: LlmBillingLedgerState.ACTIVE,
|
||||
LlmBillingExecutionStatus.PROCESSING.value: LlmBillingLedgerState.ACTIVE,
|
||||
LlmBillingExecutionStatus.SUCCEEDED.value: LlmBillingLedgerState.SUCCEEDED,
|
||||
@@ -149,13 +160,15 @@ def _state_for_execution(execution: LlmBillingExecution | None) -> LlmBillingLed
|
||||
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")
|
||||
async def charge_llm_credits(db: AsyncSession, ctx: LlmBillingContext) -> LlmChargeResult:
|
||||
"""在业务 API 事务内按后台场景配置同步真实消费积分。"""
|
||||
_log(ctx, LlmBillingEvent.CHARGE_START, status="started")
|
||||
if not ctx.billing_scene:
|
||||
raise LlmBillingConfigurationError("LLM固定预扣必须指定billing_scene")
|
||||
# 幂等执行记录查询必须位于用户积分事务锁之后。否则两个相同 API 请求
|
||||
# 可能都在锁前读到 missing,第二个虽不会重复扣分,却会撞执行记录唯一键。
|
||||
_log(ctx, LlmBillingEvent.CHARGE_CONFIG_INVALID, status="failed", error="billing_scene_missing")
|
||||
raise LlmBillingConfigurationError("LLM积分消费必须指定billing_scene")
|
||||
|
||||
# 同一 user + scene + owner + attempt 的幂等判断必须位于用户积分锁之后,
|
||||
# 避免并发 API 请求同时看到 missing 后重复消费或撞 execution 唯一键。
|
||||
await acquire_user_credit_lock(db, ctx.user_id)
|
||||
existing = await _find_execution(db, ctx, for_update=True)
|
||||
if existing:
|
||||
@@ -163,22 +176,29 @@ async def pre_deduct(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductRe
|
||||
ctx.model_config_id = existing.model_config_id
|
||||
ctx.model_name = existing.model_name_snapshot
|
||||
ctx.provider = existing.provider_snapshot
|
||||
return LlmPreDeductResult(
|
||||
ctx.model_parameters_snapshot = existing.model_parameters_snapshot
|
||||
state = _state_for_execution(existing)
|
||||
_log(
|
||||
ctx,
|
||||
LlmBillingEvent.CHARGE_IDEMPOTENT_HIT,
|
||||
amount=to_float(existing.pre_deduct_credits),
|
||||
state=_state_for_execution(existing),
|
||||
credit_record_id=existing.credit_record_id,
|
||||
execution_status=existing.status,
|
||||
ledger_state=state.value,
|
||||
)
|
||||
return LlmChargeResult(
|
||||
amount=to_float(existing.pre_deduct_credits),
|
||||
state=state,
|
||||
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,
|
||||
)
|
||||
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固定预扣配置无效")
|
||||
_log(ctx, LlmBillingEvent.CHARGE_CONFIG_INVALID, status="failed", error=policy.error)
|
||||
raise LlmBillingConfigurationError(policy.error or "LLM场景积分配置无效")
|
||||
|
||||
scene_name = _scene_name(ctx, policy.scene_name)
|
||||
meta = {
|
||||
@@ -186,8 +206,8 @@ async def pre_deduct(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductRe
|
||||
"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,
|
||||
"charge_action": CreditRecordAction.CHARGE.value,
|
||||
"credit_subject": _credit_subject(ctx),
|
||||
"billing_scene": ctx.billing_scene,
|
||||
"scene_name_snapshot": scene_name,
|
||||
"source_module": ctx.source_module,
|
||||
@@ -199,19 +219,31 @@ async def pre_deduct(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductRe
|
||||
"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(),
|
||||
)
|
||||
try:
|
||||
mutation = await deduct_credits_result(
|
||||
db,
|
||||
user_id=ctx.user_id,
|
||||
amount=policy.charge_credits,
|
||||
description=f"{scene_name}积分消费",
|
||||
related_id=ctx.related_id or ctx.owner_id,
|
||||
biz_key=ctx.charge_biz_key,
|
||||
record_meta=meta,
|
||||
record_type="consume",
|
||||
request_time=ctx.request_time or utc_now(),
|
||||
)
|
||||
except InsufficientCreditsError:
|
||||
_log(
|
||||
ctx,
|
||||
LlmBillingEvent.CHARGE_INSUFFICIENT,
|
||||
status="failed",
|
||||
error="insufficient_credits",
|
||||
amount=policy.charge_credits,
|
||||
)
|
||||
raise
|
||||
|
||||
if mutation.record is None:
|
||||
raise LlmBillingStateError("LLM固定预扣流水创建失败")
|
||||
raise LlmBillingStateError("LLM积分消费流水创建失败")
|
||||
|
||||
execution = LlmBillingExecution(
|
||||
id=generate_id(),
|
||||
user_id=ctx.user_id,
|
||||
@@ -227,18 +259,27 @@ async def pre_deduct(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductRe
|
||||
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,
|
||||
# 数据库物理字段仍沿用历史 pre_deduct_credits 命名;语义已经是本 attempt 真实消费积分。
|
||||
pre_deduct_credits=policy.charge_credits,
|
||||
credit_record_id=mutation.record.id,
|
||||
status=LlmBillingExecutionStatus.PRE_DEDUCTED.value,
|
||||
status=LlmBillingExecutionStatus.CHARGED.value,
|
||||
)
|
||||
db.add(execution)
|
||||
await db.flush()
|
||||
|
||||
# flush 后只使用已加载标量,不访问 relationship,避免 commit 后懒加载/MissingGreenlet 风险。
|
||||
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,
|
||||
_log(
|
||||
ctx,
|
||||
LlmBillingEvent.CHARGE_SUCCESS,
|
||||
amount=policy.charge_credits,
|
||||
credit_record_id=mutation.record.id,
|
||||
created=mutation.created,
|
||||
)
|
||||
return LlmChargeResult(
|
||||
amount=policy.charge_credits,
|
||||
state=LlmBillingLedgerState.ACTIVE,
|
||||
created=mutation.created,
|
||||
record_id=mutation.record.id,
|
||||
@@ -246,10 +287,18 @@ async def pre_deduct(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductRe
|
||||
)
|
||||
|
||||
|
||||
async def ensure_pre_deducted(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductValidation:
|
||||
async def ensure_llm_charged(db: AsyncSession, ctx: LlmBillingContext) -> LlmChargeValidation:
|
||||
_log(ctx, LlmBillingEvent.EXECUTION_VALIDATE_START, status="started")
|
||||
execution = await _find_execution(db, ctx)
|
||||
if execution is None:
|
||||
return LlmPreDeductValidation(False, 0.0, LlmBillingLedgerState.MISSING, "pre_deduct_missing")
|
||||
_log(
|
||||
ctx,
|
||||
LlmBillingEvent.EXECUTION_BLOCKED,
|
||||
status="failed",
|
||||
error="charge_missing",
|
||||
ledger_state=LlmBillingLedgerState.MISSING.value,
|
||||
)
|
||||
return LlmChargeValidation(False, 0.0, LlmBillingLedgerState.MISSING, "charge_missing")
|
||||
ctx.billing_execution_id = execution.id
|
||||
ctx.model_config_id = execution.model_config_id
|
||||
ctx.model_name = execution.model_name_snapshot
|
||||
@@ -261,68 +310,73 @@ async def ensure_pre_deducted(db: AsyncSession, ctx: LlmBillingContext) -> LlmPr
|
||||
_log(ctx, LlmBillingEvent.EXECUTION_BLOCKED, status="failed", execution_status=execution.status)
|
||||
else:
|
||||
_log(ctx, LlmBillingEvent.EXECUTION_VALIDATE_SUCCESS, execution_status=execution.status)
|
||||
return LlmPreDeductValidation(
|
||||
return LlmChargeValidation(
|
||||
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,
|
||||
charge_record_id=execution.credit_record_id,
|
||||
execution_id=execution.id,
|
||||
)
|
||||
|
||||
|
||||
async def get_llm_ledger_states(db: AsyncSession, contexts: Iterable[LlmBillingContext]) -> dict[str, LlmPreDeductValidation]:
|
||||
async def get_llm_ledger_states(db: AsyncSession, contexts: Iterable[LlmBillingContext]) -> dict[str, LlmChargeValidation]:
|
||||
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,
|
||||
)
|
||||
|
||||
keys = list(dict.fromkeys(
|
||||
(ctx.user_id, ctx.billing_scene or "", ctx.owner_type, ctx.owner_id, ctx.attempt_no)
|
||||
for ctx in items
|
||||
]
|
||||
result = await db.execute(select(LlmBillingExecution).where(or_(*conditions)))
|
||||
))
|
||||
result = await db.execute(
|
||||
select(LlmBillingExecution).where(
|
||||
tuple_(
|
||||
LlmBillingExecution.user_id,
|
||||
LlmBillingExecution.scene_code,
|
||||
LlmBillingExecution.owner_type,
|
||||
LlmBillingExecution.owner_id,
|
||||
LlmBillingExecution.business_attempt_no,
|
||||
).in_(keys)
|
||||
)
|
||||
)
|
||||
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] = {}
|
||||
output: dict[str, LlmChargeValidation] = {}
|
||||
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(
|
||||
output[ctx.charge_biz_key] = LlmChargeValidation(
|
||||
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,
|
||||
charge_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:
|
||||
async def validate_retryable_previous_attempt(db: AsyncSession, ctx: LlmBillingContext) -> LlmChargeValidation:
|
||||
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]
|
||||
validation = (await get_llm_ledger_states(db, [previous]))[previous.charge_biz_key]
|
||||
if ctx.attempt_no <= 1:
|
||||
return LlmPreDeductValidation(True, 0.0, LlmBillingLedgerState.MISSING, "first_attempt")
|
||||
return LlmChargeValidation(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)
|
||||
return LlmChargeValidation(allowed, validation.amount, validation.state, None if allowed else "previous_attempt_not_final", validation.charge_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)
|
||||
validation = await ensure_llm_charged(db, ctx)
|
||||
if not validation.can_execute:
|
||||
raise LlmBillingStateError("缺少有效固定预扣,禁止调用LLM")
|
||||
raise LlmBillingStateError("缺少有效LLM积分消费记录,禁止调用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 {}))
|
||||
|
||||
@@ -401,7 +455,7 @@ async def mark_business_success(
|
||||
await finish_call_success(ctx, usage=usage)
|
||||
execution = await _find_execution(db, ctx, for_update=True)
|
||||
if execution is None:
|
||||
raise LlmBillingStateError("LLM固定预扣执行记录不存在")
|
||||
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}:
|
||||
@@ -427,7 +481,7 @@ async def mark_business_success(
|
||||
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)
|
||||
_log(ctx, LlmBillingEvent.BUSINESS_SUCCESS, charge_credits=to_float(execution.pre_deduct_credits), idempotent=not created)
|
||||
return BillingSummary(
|
||||
record_id=ctx.owner_id,
|
||||
user_id=ctx.user_id,
|
||||
@@ -447,7 +501,7 @@ async def mark_business_success(
|
||||
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 的统一锁顺序。
|
||||
# 与真实消费保持 user advisory -> execution -> original record -> balance 的统一锁顺序。
|
||||
await acquire_user_credit_lock(db, ctx.user_id)
|
||||
execution = await _find_execution(db, ctx, for_update=True)
|
||||
if execution is None:
|
||||
@@ -459,21 +513,47 @@ async def refund_on_final_failure(db: AsyncSession, ctx: LlmBillingContext, *, e
|
||||
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
|
||||
|
||||
original_result = await db.execute(
|
||||
select(CreditRecord)
|
||||
.where(CreditRecord.id == execution.credit_record_id, CreditRecord.user_id == ctx.user_id)
|
||||
.limit(1)
|
||||
.with_for_update()
|
||||
)
|
||||
original_record = original_result.scalar_one_or_none()
|
||||
if original_record is None or not original_record.biz_key:
|
||||
execution.status = LlmBillingExecutionStatus.REFUND_FAILED.value
|
||||
execution.final_error_message = "原LLM积分消费流水不存在或缺少BizKey"
|
||||
execution.completed_at = utc_now()
|
||||
_log(
|
||||
ctx,
|
||||
LlmBillingEvent.FINAL_FAILURE_REFUND_FAILED,
|
||||
status="failed",
|
||||
error=execution.final_error_message,
|
||||
credit_record_id=execution.credit_record_id,
|
||||
)
|
||||
raise LlmBillingStateError(execution.final_error_message)
|
||||
|
||||
execution.status = LlmBillingExecutionStatus.FINAL_FAILED.value
|
||||
execution.final_error_message = str(error or "业务最终失败")[:5000]
|
||||
execution.completed_at = utc_now()
|
||||
_log(ctx, LlmBillingEvent.FINAL_FAILURE_START, status="started", error=error)
|
||||
original_biz_key = original_record.biz_key
|
||||
_log(
|
||||
ctx,
|
||||
LlmBillingEvent.FINAL_FAILURE_START,
|
||||
status="started",
|
||||
error=error,
|
||||
credit_record_id=original_record.id,
|
||||
original_biz_key=original_biz_key,
|
||||
)
|
||||
try:
|
||||
refunded = await refund_consumption(
|
||||
db,
|
||||
user_id=ctx.user_id,
|
||||
refund_for_biz_key=ctx.pre_deduct_biz_key,
|
||||
refund_for_biz_key=original_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",
|
||||
biz_key=f"{original_biz_key}:business-final-refund",
|
||||
record_meta={
|
||||
"owner_type": ctx.owner_type,
|
||||
"owner_id": ctx.owner_id,
|
||||
@@ -500,6 +580,7 @@ async def refund_on_final_failure(db: AsyncSession, ctx: LlmBillingContext, *, e
|
||||
LlmBillingEvent.FINAL_FAILURE_REFUND_SUCCESS,
|
||||
refund_available=to_float(refunded.available_amount),
|
||||
refund_expired=to_float(refunded.expired_amount),
|
||||
credit_record_id=original_record.id,
|
||||
)
|
||||
return BillingSummary(
|
||||
ctx.owner_id,
|
||||
@@ -508,15 +589,10 @@ async def refund_on_final_failure(db: AsyncSession, ctx: LlmBillingContext, *, e
|
||||
)
|
||||
except Exception as exc:
|
||||
execution.status = LlmBillingExecutionStatus.REFUND_FAILED.value
|
||||
_log(ctx, LlmBillingEvent.FINAL_FAILURE_REFUND_FAILED, status="failed", error=str(exc))
|
||||
_log(ctx, LlmBillingEvent.FINAL_FAILURE_REFUND_FAILED, status="failed", error=str(exc), credit_record_id=original_record.id)
|
||||
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,
|
||||
*,
|
||||
|
||||
@@ -38,7 +38,7 @@ from app.services.redis_registry_service import (
|
||||
utc_now,
|
||||
)
|
||||
from app.services.celery_runtime.runtime_service import CeleryRuntimeLease, RuntimeIdentity, runtime_lock_values
|
||||
from app.services.llm_billing import LlmBillingContext, LlmPreDeductValidation, get_llm_ledger_states
|
||||
from app.services.llm_billing import LlmBillingContext, LlmChargeValidation, get_llm_ledger_states
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
logger = logging.getLogger("video_gen")
|
||||
@@ -92,7 +92,7 @@ def _step_llm_billing_context(step: ModuleGenerationStep) -> LlmBillingContext:
|
||||
source_step_id=str(step.id),
|
||||
source_step_code=str(step.step_code),
|
||||
related_id=str(step.id),
|
||||
description_prefix="模块AI提词优化",
|
||||
description_prefix="模块AI提词优化",
|
||||
trace_id=f"module-recovery:{step.id}:attempt:{max(1, int(step.version or 1))}",
|
||||
)
|
||||
|
||||
@@ -100,24 +100,24 @@ def _step_llm_billing_context(step: ModuleGenerationStep) -> LlmBillingContext:
|
||||
async def _load_step_billing_validations(
|
||||
db: AsyncSession,
|
||||
steps: Iterable[ModuleGenerationStep],
|
||||
) -> dict[str, LlmPreDeductValidation]:
|
||||
) -> dict[str, LlmChargeValidation]:
|
||||
step_list = list(steps)
|
||||
if not step_list:
|
||||
return {}
|
||||
contexts = {str(step.id): _step_llm_billing_context(step) for step in step_list}
|
||||
# 固定预扣已经在 API 层完成;恢复任务只认同一业务 attempt 的账务执行记录,
|
||||
# 不再读取旧 SystemConfig 冻结配置;缺少固定预扣记录时直接终止恢复。
|
||||
# 场景积分消费已经在 API 层完成;恢复任务只认同一业务 attempt 的账务执行记录,
|
||||
# 不再读取旧 SystemConfig 冻结配置;缺少场景积分消费记录时直接终止恢复。
|
||||
ledger_states = await get_llm_ledger_states(db, contexts.values())
|
||||
output: dict[str, LlmPreDeductValidation] = {}
|
||||
output: dict[str, LlmChargeValidation] = {}
|
||||
for step_id, ctx in contexts.items():
|
||||
ledger = ledger_states.get(
|
||||
ctx.pre_deduct_biz_key,
|
||||
LlmPreDeductValidation(False, 0.0, LlmBillingLedgerState.MISSING, "ledger_not_loaded"),
|
||||
ctx.charge_biz_key,
|
||||
LlmChargeValidation(False, 0.0, LlmBillingLedgerState.MISSING, "ledger_not_loaded"),
|
||||
)
|
||||
if ledger.state == LlmBillingLedgerState.ACTIVE:
|
||||
output[step_id] = ledger
|
||||
elif ledger.state == LlmBillingLedgerState.MISSING:
|
||||
output[step_id] = LlmPreDeductValidation(
|
||||
output[step_id] = LlmChargeValidation(
|
||||
False,
|
||||
0.0,
|
||||
LlmBillingLedgerState.INVALID,
|
||||
|
||||
@@ -50,12 +50,12 @@ from app.services.generation.pipeline.db_lock_service import (
|
||||
from app.services.generation.task_factory_service import create_chat_generation_task_for_module
|
||||
from app.services.llm_billing import (
|
||||
LlmBillingContext,
|
||||
ensure_pre_deducted,
|
||||
ensure_llm_charged,
|
||||
log_provider_failure,
|
||||
log_provider_start,
|
||||
log_provider_success,
|
||||
mark_business_success,
|
||||
pre_deduct,
|
||||
charge_llm_credits,
|
||||
record_provider_exception,
|
||||
finalize_llm_business_failure,
|
||||
)
|
||||
@@ -432,7 +432,7 @@ async def create_hot_opening_project_v2(
|
||||
video_config=video_config,
|
||||
target_platform=req.target_platform or "抖音",
|
||||
)
|
||||
await pre_deduct(
|
||||
await charge_llm_credits(
|
||||
db,
|
||||
build_v2_video_prompt_billing_context(
|
||||
user_id=str(current_user.id),
|
||||
@@ -599,7 +599,7 @@ async def create_shot_replicate_project_v2(
|
||||
urls=[req.material_image_url],
|
||||
allow_common_migrate=True,
|
||||
)
|
||||
await pre_deduct(
|
||||
await charge_llm_credits(
|
||||
db,
|
||||
build_v2_video_prompt_billing_context(
|
||||
user_id=str(current_user.id),
|
||||
@@ -723,7 +723,7 @@ async def rebuild_video_prompt_step_v2(
|
||||
project.final_video_cover_url = None
|
||||
project.completed_at = None
|
||||
project.error_message = None
|
||||
await pre_deduct(
|
||||
await charge_llm_credits(
|
||||
db,
|
||||
build_v2_video_prompt_billing_context(
|
||||
user_id=str(project.user_id),
|
||||
@@ -887,10 +887,10 @@ async def run_video_prompt_optimize_v2(
|
||||
description_prefix=f"{config.display_name}视频提词优化",
|
||||
trace_id=f"module-v2-video-prompt:{project_snapshot['step_id']}",
|
||||
)
|
||||
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
|
||||
if not pre_deduct_validation.can_execute:
|
||||
charge_validation = await ensure_llm_charged(db, llm_billing_context)
|
||||
if not charge_validation.can_execute:
|
||||
step.status = ModuleStepStatusEnum.FAILED.value
|
||||
step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务"
|
||||
step.error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止任务"
|
||||
step.completed_at = utc_now()
|
||||
project.status = ModuleProjectStatusEnum.FAILED.value
|
||||
project.error_message = step.error_message
|
||||
|
||||
@@ -58,7 +58,7 @@ from app.services.module_generation_log_service import log_module_error, log_mod
|
||||
from app.services.llm import optimize_prompt
|
||||
from app.services.llm_billing import (
|
||||
LlmBillingContext,
|
||||
ensure_pre_deducted,
|
||||
ensure_llm_charged,
|
||||
log_provider_failure,
|
||||
record_provider_exception,
|
||||
log_provider_start,
|
||||
@@ -66,7 +66,7 @@ from app.services.llm_billing import (
|
||||
refund_on_final_failure,
|
||||
finalize_llm_business_failure,
|
||||
mark_business_success,
|
||||
pre_deduct,
|
||||
charge_llm_credits,
|
||||
)
|
||||
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,
|
||||
@@ -842,7 +842,7 @@ 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 pre_deduct(
|
||||
await charge_llm_credits(
|
||||
db,
|
||||
LlmBillingContext(
|
||||
user_id=str(project.user_id),
|
||||
@@ -957,10 +957,10 @@ async def run_image_prompt_optimize(
|
||||
description_prefix="拆镜复刻图片AI提词优化",
|
||||
trace_id=f"shot-image-prompt:{step_id_value}",
|
||||
)
|
||||
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
|
||||
if not pre_deduct_validation.can_execute:
|
||||
charge_validation = await ensure_llm_charged(db, llm_billing_context)
|
||||
if not charge_validation.can_execute:
|
||||
step.status = ModuleStepStatusEnum.FAILED.value
|
||||
step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务"
|
||||
step.error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止任务"
|
||||
step.completed_at = _now()
|
||||
project.status = ModuleProjectStatusEnum.FAILED.value
|
||||
project.error_message = step.error_message
|
||||
@@ -1286,7 +1286,7 @@ 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 pre_deduct(
|
||||
await charge_llm_credits(
|
||||
db,
|
||||
LlmBillingContext(
|
||||
user_id=str(project.user_id),
|
||||
@@ -1399,10 +1399,10 @@ async def run_video_prompt_optimize(
|
||||
description_prefix="拆镜复刻视频AI提词优化",
|
||||
trace_id=f"shot-video-prompt:{step_id_value}",
|
||||
)
|
||||
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
|
||||
if not pre_deduct_validation.can_execute:
|
||||
charge_validation = await ensure_llm_charged(db, llm_billing_context)
|
||||
if not charge_validation.can_execute:
|
||||
step.status = ModuleStepStatusEnum.FAILED.value
|
||||
step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务"
|
||||
step.error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止任务"
|
||||
step.completed_at = _now()
|
||||
project.status = ModuleProjectStatusEnum.FAILED.value
|
||||
project.error_message = step.error_message
|
||||
|
||||
@@ -233,7 +233,7 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]:
|
||||
*(build_task_set_analysis_billing_context(item) for item in task_sets),
|
||||
*(build_segment_analysis_billing_context(item) for item in segments),
|
||||
]
|
||||
# 固定预扣配置变更后仍需识别并继续处理已存在的有效预扣;缺少预扣记录时禁止恢复。
|
||||
# 场景积分消费配置变更后仍需识别并继续处理已存在的有效消费;缺少消费记录时禁止恢复。
|
||||
billing_states = await get_llm_ledger_states(db, billing_contexts)
|
||||
|
||||
lock_keys: list[str] = []
|
||||
@@ -259,7 +259,7 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]:
|
||||
results["skip_live_task_set_lock"] = results.get("skip_live_task_set_lock", 0) + 1
|
||||
continue
|
||||
context = build_task_set_analysis_billing_context(item)
|
||||
validation = billing_states.get(context.pre_deduct_biz_key)
|
||||
validation = billing_states.get(context.charge_biz_key)
|
||||
can_execute = bool(validation and validation.can_execute)
|
||||
state = validation.state.value if validation else LlmBillingLedgerState.MISSING.value
|
||||
if validation and validation.state == LlmBillingLedgerState.MISSING:
|
||||
@@ -289,7 +289,7 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]:
|
||||
results["skip_live_segment_lock"] = results.get("skip_live_segment_lock", 0) + 1
|
||||
continue
|
||||
context = build_segment_analysis_billing_context(item)
|
||||
validation = billing_states.get(context.pre_deduct_biz_key)
|
||||
validation = billing_states.get(context.charge_biz_key)
|
||||
can_execute = bool(validation and validation.can_execute)
|
||||
state = validation.state.value if validation else LlmBillingLedgerState.MISSING.value
|
||||
if validation and validation.state == LlmBillingLedgerState.MISSING:
|
||||
|
||||
@@ -56,7 +56,7 @@ from app.services.module_generation_log_service import log_module_event_file
|
||||
from app.services.llm_billing import (
|
||||
LlmBillingContext,
|
||||
refund_on_final_failure,
|
||||
pre_deduct,
|
||||
charge_llm_credits,
|
||||
validate_retryable_previous_attempt,
|
||||
)
|
||||
from app.services.resource_accounting_service import SOURCE_MODEL_SHOT_SEGMENT, soft_delete_resources_by_source
|
||||
@@ -244,7 +244,7 @@ async def create_task_set(
|
||||
)
|
||||
existing = existing_result.scalar_one_or_none()
|
||||
if existing:
|
||||
# 幂等命中只返回已有任务,不重复预扣、绑定资源或投递 Celery。
|
||||
# 幂等命中只返回已有任务,不重复消费积分、绑定资源或投递 Celery。
|
||||
return existing, False
|
||||
|
||||
asset = validate_upload_video_asset(req.video_url, req.video_duration_seconds)
|
||||
@@ -265,7 +265,7 @@ async def create_task_set(
|
||||
)
|
||||
db.add(task_set)
|
||||
await db.flush()
|
||||
await pre_deduct(db, build_task_set_analysis_billing_context(task_set))
|
||||
await charge_llm_credits(db, build_task_set_analysis_billing_context(task_set))
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type="SHOT_TASK_SET_CREATED",
|
||||
@@ -683,7 +683,7 @@ async def prepare_retry_split_segment(
|
||||
await refresh_task_set_split_summary(db, task_set.id)
|
||||
await db.flush()
|
||||
|
||||
# 切片重试只重放本地视频切割,不创建新的 LLM attempt,也不重复预扣。
|
||||
# 切片重试只重放本地视频切割,不创建新的 LLM attempt,也不重复消费积分。
|
||||
# 切片成功后仍会继续原 attempt 的片段分析;显式重新分析才走 reanalyze_segment。
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
@@ -748,7 +748,7 @@ async def create_custom_segment(
|
||||
task_set.status = ShotTaskSetStatusEnum.SPLITTING.value
|
||||
task_set.split_status = ShotSplitStatusEnum.PROCESSING.value
|
||||
await db.flush()
|
||||
await pre_deduct(db, build_segment_analysis_billing_context(segment))
|
||||
await charge_llm_credits(db, build_segment_analysis_billing_context(segment))
|
||||
await refresh_task_set_split_summary(db, task_set.id)
|
||||
await db.flush()
|
||||
log_module_event_file(
|
||||
@@ -920,7 +920,7 @@ async def delete_segment(
|
||||
await refund_on_final_failure(
|
||||
db,
|
||||
build_segment_analysis_billing_context(segment),
|
||||
error="用户删除自定义拆镜片段,释放未结算的片段分析预扣",
|
||||
error="用户删除自定义拆镜片段,退回片段分析消费积分",
|
||||
)
|
||||
|
||||
deleted_at = _now()
|
||||
@@ -979,7 +979,7 @@ async def delete_segment(
|
||||
"pending_delete_resource_count": len(pending_delete_resource_ids),
|
||||
"physical_file_delete": "after_commit",
|
||||
"media_refund": False,
|
||||
"llm_pre_deduct_refund_on_cancel": segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value,
|
||||
"llm_charge_refund_on_cancel": segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1037,14 +1037,14 @@ async def delete_task_set(
|
||||
await refund_on_final_failure(
|
||||
db,
|
||||
build_task_set_analysis_billing_context(task_set),
|
||||
error="用户删除拆镜任务集,释放未结算的原视频分析预扣",
|
||||
error="用户删除拆镜任务集,退回原视频分析消费积分",
|
||||
)
|
||||
for segment in segments:
|
||||
if segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value:
|
||||
await refund_on_final_failure(
|
||||
db,
|
||||
build_segment_analysis_billing_context(segment),
|
||||
error="用户删除拆镜任务集,释放未结算的片段分析预扣",
|
||||
error="用户删除拆镜任务集,退回片段分析消费积分",
|
||||
)
|
||||
|
||||
segment_ids = [segment.id for segment in segments]
|
||||
@@ -1110,7 +1110,7 @@ async def delete_task_set(
|
||||
"segment_upload_release": {k: v for k, v in segment_upload_release.items() if k != "released_resource_ids"},
|
||||
"physical_file_delete": "after_commit",
|
||||
"media_refund": False,
|
||||
"llm_pre_deduct_refund_on_cancel": True,
|
||||
"llm_charge_refund_on_cancel": True,
|
||||
},
|
||||
)
|
||||
return ShotTaskSetDeleteOut(
|
||||
@@ -1195,7 +1195,7 @@ async def prepare_reanalyze_task_set(
|
||||
task_set.analysis_raw_json = None
|
||||
task_set.analysis_result_json = None
|
||||
await db.flush()
|
||||
await pre_deduct(db, build_task_set_analysis_billing_context(task_set))
|
||||
await charge_llm_credits(db, build_task_set_analysis_billing_context(task_set))
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_RECEIVED.value,
|
||||
@@ -1301,7 +1301,7 @@ async def prepare_reanalyze_segment(
|
||||
segment.segment_category = None
|
||||
segment.segment_audience = None
|
||||
await db.flush()
|
||||
await pre_deduct(db, build_segment_analysis_billing_context(segment))
|
||||
await charge_llm_credits(db, build_segment_analysis_billing_context(segment))
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_RECEIVED.value,
|
||||
@@ -1420,7 +1420,7 @@ async def mark_custom_segment_split_dispatch_failed(
|
||||
segment.split_next_retry_at = None
|
||||
segment.split_last_error = error_message
|
||||
# 切片投递失败不改变 LLM attempt 的冻结状态。用户重试切片时继续沿用
|
||||
# 原固定预扣;只有片段分析最终失败或用户删除片段时才按原来源退款。
|
||||
# 原场景积分消费;只有片段分析最终失败或用户删除片段时才按原来源退款。
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
|
||||
|
||||
@@ -39,7 +39,7 @@ from app.services.shot_replicate_taskset_service import (
|
||||
from app.services.shot_video_analysis_service import analyze_video_for_shot_split
|
||||
from app.services.llm_billing import (
|
||||
LlmBillingContext,
|
||||
ensure_pre_deducted,
|
||||
ensure_llm_charged,
|
||||
log_provider_failure,
|
||||
log_provider_start,
|
||||
log_provider_success,
|
||||
@@ -474,9 +474,9 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int
|
||||
description_prefix="拆镜复刻原视频分析",
|
||||
trace_id=f"shot-task-set-analysis:{task_set_id}:attempt:{attempt_no}",
|
||||
)
|
||||
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
|
||||
if not pre_deduct_validation.can_execute:
|
||||
error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止原视频分析任务"
|
||||
charge_validation = await ensure_llm_charged(db, llm_billing_context)
|
||||
if not charge_validation.can_execute:
|
||||
error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止原视频分析任务"
|
||||
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value
|
||||
task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value
|
||||
task_set.analysis_claim_token = None
|
||||
@@ -746,9 +746,9 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no
|
||||
description_prefix="拆镜复刻片段视频分析",
|
||||
trace_id=f"shot-segment-analysis:{segment_id}:attempt:{attempt_no}",
|
||||
)
|
||||
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
|
||||
if not pre_deduct_validation.can_execute:
|
||||
error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止片段视频分析任务"
|
||||
charge_validation = await ensure_llm_charged(db, llm_billing_context)
|
||||
if not charge_validation.can_execute:
|
||||
error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止片段视频分析任务"
|
||||
segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
|
||||
segment.analysis_claim_token = None
|
||||
segment.analysis_lease_until = None
|
||||
@@ -957,7 +957,7 @@ async def _mark_auto_segment_analysis_dispatch_failed(
|
||||
project_id=task_set_id,
|
||||
step_id=segment_id,
|
||||
user_id=user_id,
|
||||
message="自定义切片分析任务投递失败,已标记分析失败并退回固定预扣积分",
|
||||
message="自定义切片分析任务投递失败,已标记分析失败并退回场景消费积分",
|
||||
detail={
|
||||
"segment_id": segment_id,
|
||||
"task_set_id": task_set_id,
|
||||
@@ -1216,7 +1216,7 @@ async def _run_split_one_segment(segment_id: str) -> None:
|
||||
segment.split_status = ShotSplitStatusEnum.FAILED.value
|
||||
segment.split_next_retry_at = None
|
||||
final_failed = True
|
||||
# 切片最终失败不退回片段分析固定预扣;手动切片重试继续沿用
|
||||
# 切片最终失败不退回片段分析场景积分消费;手动切片重试继续沿用
|
||||
# 原 attempt。用户最终删除片段/任务集时再做取消补偿。
|
||||
else:
|
||||
segment.split_status = ShotSplitStatusEnum.RETRY_WAITING.value
|
||||
|
||||
Reference in New Issue
Block a user