This commit is contained in:
2026-08-13 09:38:36 +08:00
124 changed files with 11236 additions and 5549 deletions
@@ -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,
@@ -20,6 +25,7 @@ from app.enums.credit_record import (
from app.enums.user import FRONTEND_USER_KIND_LABELS, USER_TYPE_LABELS, UserType
from app.enums.team import TEAM_UNASSIGNED_VALUE
from app.models.chat_generation_task import ChatGenerationTask
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit_record import CreditRecord
from app.models.generation_record import GenerationRecord
from app.models.module_generation_project import ModuleGenerationProject
@@ -128,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:
@@ -171,7 +193,31 @@ async def _load_deleted_map(db: AsyncSession, records: list[CreditRecord]) -> di
return deleted_map
def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[tuple[str, str], tuple[bool, str | None]]) -> dict[str, Any]:
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,
deleted_map: dict[tuple[str, str], tuple[bool, str | None]],
allocation_map: dict[str, list[dict[str, Any]]],
) -> dict[str, Any]:
owner_deleted = False
owner_deleted_at = None
if record.owner_type and record.owner_id:
@@ -179,6 +225,7 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t
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,
@@ -195,8 +242,10 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t
"record_type": record.type,
"record_type_label": _label(CREDIT_RECORD_TYPE_LABELS, record.type),
"amount": _round2(record.amount),
"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,
@@ -207,14 +256,16 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t
"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,
"media_type_label": _label(CREDIT_RECORD_MEDIA_TYPE_LABELS, record.media_type),
"billing_scene": record.billing_scene,
"billing_scene_label": _label(CREDIT_RECORD_BILLING_SCENE_LABELS, record.billing_scene),
"scene_name_snapshot": record.scene_name_snapshot,
"request_time": _iso(record.request_time),
"source_module": record.source_module,
"source_module_label": _label(CREDIT_RECORD_SOURCE_MODULE_LABELS, record.source_module),
"source_project_id": record.source_project_id,
@@ -225,6 +276,10 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t
"input_tokens": record.input_tokens or 0,
"output_tokens": record.output_tokens or 0,
"total_tokens": record.total_tokens or 0,
"llm_call_count": record.llm_call_count or 0,
"llm_success_call_count": record.llm_success_call_count or 0,
"llm_failed_call_count": record.llm_failed_call_count or 0,
"allocations": allocation_map.get(record.id, []),
"engine_type": record.engine_type,
"engine_id": record.engine_id,
"engine_name": record.engine_name,
@@ -293,63 +348,69 @@ async def list_admin_credit_records(
rows = result.all()
records = [row[0] for row in rows]
deleted_map = await _load_deleted_map(db, records)
items = [_record_to_item(record, user, deleted_map) for record, user in rows]
record_ids = [record.id for record in records]
allocation_map: dict[str, list[dict[str, Any]]] = {record_id: [] for record_id in record_ids}
if record_ids:
allocation_result = await db.execute(
select(CreditRecordAllocation)
.where(CreditRecordAllocation.credit_record_id.in_(record_ids))
.order_by(CreditRecordAllocation.credit_record_id.asc(), CreditRecordAllocation.created_at.asc(), CreditRecordAllocation.id.asc())
)
for allocation in allocation_result.scalars().all():
allocation_map.setdefault(allocation.credit_record_id, []).append({
"id": allocation.id,
"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),
"unspent_before": _round2(allocation.unspent_before),
"unspent_after": _round2(allocation.unspent_after),
"consumed_before": _round2(allocation.consumed_before),
"consumed_after": _round2(allocation.consumed_after),
})
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))),
@@ -358,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:
@@ -0,0 +1,35 @@
from app.services.credit.expiration_service import (
archive_expired_balances_batch,
archive_expired_user_balances,
)
from app.services.credit.ledger_service import (
CreditMutationResult,
CreditRefundResult,
deduct_credits,
grant_credits,
refund_consumption,
revoke_balances,
)
from app.services.credit.query_service import (
CreditBalanceSummary,
attach_credit_snapshot,
get_available_credits,
get_balance_summary,
get_user_credit_map,
)
__all__ = [
"CreditMutationResult",
"CreditRefundResult",
"CreditBalanceSummary",
"deduct_credits",
"grant_credits",
"refund_consumption",
"revoke_balances",
"archive_expired_balances_batch",
"archive_expired_user_balances",
"attach_credit_snapshot",
"get_available_credits",
"get_balance_summary",
"get_user_credit_map",
]
@@ -0,0 +1,138 @@
from __future__ import annotations
from collections import Counter
from datetime import datetime
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_balance import CreditAllocationAction, CreditBalanceStatus
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordType
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit.balance import UserCreditBalance
from app.models.credit_record import CreditRecord
from app.services.credit.locking import acquire_user_credit_lock
from app.services.credit.query_service import get_available_credits
from app.services.credit.utils import to_credit_decimal, utc_now
from app.utils.id_gen import generate_id
async def archive_expired_user_balances(
db: AsyncSession,
*,
user_id: str,
request_time: datetime | None = None,
limit: int = 500,
) -> int:
checked_at = request_time or utc_now()
await acquire_user_credit_lock(db, user_id)
result = await db.execute(
select(UserCreditBalance)
.where(
UserCreditBalance.user_id == user_id,
UserCreditBalance.expires_at <= checked_at,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.expired_processed_at.is_(None),
UserCreditBalance.revoked_at.is_(None),
)
.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc())
.limit(max(1, limit))
.with_for_update()
)
balances = list(result.scalars().all())
if not balances:
return 0
current_available = await get_available_credits(db, user_id, request_time=checked_at)
for balance in balances:
amount = to_credit_decimal(balance.unspent_amount)
if amount <= 0:
continue
before_consumed = to_credit_decimal(balance.consumed_amount)
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type=CreditRecordType.EXPIRE.value,
amount=-amount,
balance_delta=Decimal("0.00"),
expired_amount=amount,
balance_after=current_available,
description=f"积分到期:{float(amount):.2f}",
related_id=balance.id,
request_time=checked_at,
biz_key=f"credit-balance:{balance.id}:expire",
billing_scene=CreditRecordBillingScene.CREDIT_EXPIRE.value,
credit_level_snapshot=balance.credit_level,
)
db.add(record)
await db.flush()
db.add(
CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=balance.id,
user_id=user_id,
allocation_action=CreditAllocationAction.EXPIRE.value,
amount=amount,
request_time=checked_at,
credit_level_snapshot=balance.credit_level,
source_type_snapshot=balance.source_type,
source_id_snapshot=balance.source_id,
valid_from_snapshot=balance.valid_from,
expires_at_snapshot=balance.expires_at,
unspent_before=amount,
unspent_after=Decimal("0.00"),
consumed_before=before_consumed,
consumed_after=before_consumed,
)
)
balance.unspent_amount = Decimal("0.00")
balance.expired_amount = to_credit_decimal(balance.expired_amount) + amount
balance.expired_processed_at = checked_at
balance.status = CreditBalanceStatus.EXPIRED.value
await db.flush()
return len(balances)
async def list_expired_balance_user_limits(
db: AsyncSession,
*,
request_time: datetime,
batch_size: int = 500,
) -> list[tuple[str, int]]:
result = await db.execute(
select(UserCreditBalance.id, UserCreditBalance.user_id)
.where(
UserCreditBalance.expires_at <= request_time,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.expired_processed_at.is_(None),
UserCreditBalance.revoked_at.is_(None),
)
.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc())
.limit(max(1, batch_size))
)
selected = [(str(row.id), str(row.user_id)) for row in result.all()]
per_user_limit = Counter(user_id for _, user_id in selected)
return sorted(per_user_limit.items(), key=lambda item: item[0])
async def archive_expired_balances_batch(
db: AsyncSession,
*,
request_time: datetime | None = None,
batch_size: int = 500,
) -> int:
checked_at = request_time or utc_now()
user_limits = await list_expired_balance_user_limits(
db, request_time=checked_at, batch_size=batch_size
)
processed = 0
for user_id, user_limit in user_limits:
processed += await archive_expired_user_balances(
db,
user_id=user_id,
request_time=checked_at,
limit=user_limit,
)
return processed
@@ -0,0 +1,791 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from typing import Iterable
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_balance import (
CREDIT_LEVEL_SORT,
CreditAllocationAction,
CreditBalanceSourceType,
CreditBalanceStatus,
CreditLevel,
)
from app.enums.credit_record import (
CreditRecordAction,
CreditRecordBillingScene,
CreditRecordType,
)
from app.enums.common import BillingBlockEventEnum
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit.balance import UserCreditBalance
from app.models.credit_record import CreditRecord
from app.models.user import User
from app.services.credit.locking import acquire_user_credit_lock
from app.services.credit.query_service import attach_credit_snapshot, get_available_credits
from app.services.credit.time_policy import add_natural_months
from app.services.credit.utils import to_credit_decimal, to_float, utc_now
from app.services.credit_record_meta_service import CreditRecordMeta, with_user_snapshot
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 CreditMutationResult:
user: User
record: CreditRecord | None
created: bool
amount: float
balance_before: float
balance_after: float
refund_available: float = 0.0
refund_expired: float = 0.0
@dataclass(slots=True, frozen=True)
class _RefundAllocationSource:
original_allocation: CreditRecordAllocation
credit_balance_id: str
amount: Decimal
@dataclass(slots=True, frozen=True)
class CreditRefundResult:
records: tuple[CreditRecord, ...]
created: bool
total_amount: Decimal
available_amount: Decimal
expired_amount: Decimal
balance_before: Decimal
balance_after: Decimal
async def _load_user(db: AsyncSession, user_id: str) -> User:
result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = result.scalar_one_or_none()
if user is None:
raise ValueError("User not found")
return user
async def _record_meta_kwargs(
db: AsyncSession,
user: User,
record_meta: CreditRecordMeta | dict | None,
) -> dict:
if isinstance(record_meta, CreditRecordMeta):
populated = await with_user_snapshot(db, record_meta, user.id, user=user)
return populated.to_record_kwargs()
if isinstance(record_meta, dict):
return {key: value for key, value in record_meta.items() if value is not None}
return {}
async def _find_record_by_biz_key(
db: AsyncSession,
*,
user_id: str,
biz_key: str | None,
) -> CreditRecord | None:
if not biz_key:
return None
result = await db.execute(
select(CreditRecord)
.where(CreditRecord.user_id == user_id, CreditRecord.biz_key == biz_key)
.limit(1)
)
return result.scalar_one_or_none()
async def grant_credits(
db: AsyncSession,
*,
user_id: str,
amount: Decimal | float | int,
description: str,
source_type: str,
valid_from: datetime | None = None,
expires_at: datetime | None = None,
credit_level: str = CreditLevel.GENERAL.value,
source_id: str | None = None,
product_id: str | None = None,
payment_order_id: str | None = None,
subscription_id: str | None = None,
subscription_period_id: str | None = None,
related_id: str | None = None,
record_type: str = CreditRecordType.RECHARGE.value,
biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
metadata_json: dict | None = None,
request_time: datetime | None = None,
) -> CreditMutationResult:
grant_amount = to_credit_decimal(amount)
checked_at = request_time or utc_now()
starts_at = valid_from or checked_at
ends_at = expires_at or add_natural_months(starts_at, 1)
await acquire_user_credit_lock(db, user_id)
user = await _load_user(db, user_id)
before = await get_available_credits(db, user_id, request_time=checked_at)
existing = await _find_record_by_biz_key(db, user_id=user_id, biz_key=biz_key)
if existing is not None:
attach_credit_snapshot(user, before)
return CreditMutationResult(
user=user,
record=existing,
created=False,
amount=abs(to_float(existing.amount)),
balance_before=to_float(before),
balance_after=to_float(before),
)
if grant_amount <= 0:
attach_credit_snapshot(user, before)
return CreditMutationResult(user, None, False, 0.0, to_float(before), to_float(before))
if ends_at <= starts_at:
raise ValueError("积分过期时间必须晚于生效时间")
meta_kwargs = await _record_meta_kwargs(db, user, record_meta)
after = before + grant_amount if starts_at <= checked_at < ends_at else before
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type=record_type,
amount=grant_amount,
balance_delta=(grant_amount if starts_at <= checked_at < ends_at else Decimal("0.00")),
expired_amount=Decimal("0.00"),
balance_after=after,
description=description,
related_id=related_id,
request_time=checked_at,
biz_key=biz_key,
credit_level_snapshot=credit_level,
**meta_kwargs,
)
db.add(record)
await db.flush()
balance = UserCreditBalance(
id=generate_id(),
user_id=user_id,
credit_level=credit_level,
credit_level_rank=CREDIT_LEVEL_SORT.get(credit_level, CREDIT_LEVEL_SORT[CreditLevel.GENERAL.value]),
source_type=source_type,
source_id=source_id,
product_id=product_id,
payment_order_id=payment_order_id,
subscription_id=subscription_id,
subscription_period_id=subscription_period_id,
grant_record_id=record.id,
grant_amount=grant_amount,
unspent_amount=grant_amount,
consumed_amount=Decimal("0.00"),
expired_amount=Decimal("0.00"),
revoked_amount=Decimal("0.00"),
valid_from=starts_at,
expires_at=ends_at,
status=CreditBalanceStatus.ACTIVE.value,
biz_key=f"{biz_key or record.id}:balance",
metadata_json=metadata_json,
)
db.add(balance)
await db.flush()
allocation = CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=balance.id,
user_id=user_id,
allocation_action=CreditAllocationAction.GRANT.value,
amount=grant_amount,
request_time=checked_at,
credit_level_snapshot=credit_level,
source_type_snapshot=source_type,
source_id_snapshot=source_id,
valid_from_snapshot=starts_at,
expires_at_snapshot=ends_at,
unspent_before=Decimal("0.00"),
unspent_after=grant_amount,
consumed_before=Decimal("0.00"),
consumed_after=Decimal("0.00"),
)
db.add(allocation)
attach_credit_snapshot(user, after)
await db.flush()
log_operation_event(
domain="billing",
module="dynamic_credit",
event_type="CREDIT_GRANTED",
event_status="success",
source="app.services.credit.ledger_service.grant_credits",
user_id=user_id,
task_id=related_id,
message="动态积分发放完成",
detail={
"record_id": record.id,
"balance_id": balance.id,
"amount": to_float(grant_amount),
"source_type": source_type,
"credit_level": credit_level,
"valid_from": starts_at.isoformat(),
"expires_at": ends_at.isoformat(),
"biz_key": biz_key,
},
)
return CreditMutationResult(
user=user,
record=record,
created=True,
amount=to_float(grant_amount),
balance_before=to_float(before),
balance_after=to_float(after),
)
async def deduct_credits(
db: AsyncSession,
*,
user_id: str,
amount: Decimal | float | int,
description: str,
related_id: str | None = None,
biz_key: str | None = None,
refund_for_biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
record_type: str = CreditRecordType.CONSUME.value,
create_zero_record: bool = False,
request_time: datetime | None = None,
) -> CreditMutationResult:
consume_amount = to_credit_decimal(amount)
checked_at = request_time or utc_now()
await acquire_user_credit_lock(db, user_id)
user = await _load_user(db, user_id)
before = await get_available_credits(db, user_id, request_time=checked_at)
existing = await _find_record_by_biz_key(db, user_id=user_id, biz_key=biz_key)
if existing is not None:
attach_credit_snapshot(user, before)
return CreditMutationResult(
user=user,
record=existing,
created=False,
amount=abs(to_float(existing.amount)),
balance_before=to_float(before),
balance_after=to_float(before),
)
if consume_amount <= 0 and not create_zero_record:
attach_credit_snapshot(user, before)
return CreditMutationResult(user, None, False, 0.0, to_float(before), to_float(before))
if consume_amount > before:
log_operation_event(
domain="billing",
module="dynamic_credit",
event_type=BillingBlockEventEnum.INSUFFICIENT_CREDITS.value,
event_status="failed",
source="app.services.credit.ledger_service.deduct_credits",
user_id=user_id,
task_id=related_id,
message="有效积分不足,已在创建业务任务前同步拦截",
detail={
"required_credits": to_float(consume_amount),
"available_credits": to_float(before),
"biz_key": biz_key,
"request_time": checked_at.isoformat(),
"description": description,
},
)
raise InsufficientCreditsError()
result = await db.execute(
select(UserCreditBalance)
.where(
UserCreditBalance.user_id == user_id,
UserCreditBalance.valid_from <= checked_at,
UserCreditBalance.expires_at > checked_at,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.revoked_at.is_(None),
)
.order_by(
UserCreditBalance.credit_level_rank.asc(),
UserCreditBalance.expires_at.asc(),
UserCreditBalance.valid_from.asc(),
UserCreditBalance.id.asc(),
)
.with_for_update()
)
balances = list(result.scalars().all())
remaining = consume_amount
allocations_data: list[tuple[UserCreditBalance, Decimal, Decimal, Decimal, Decimal, Decimal]] = []
for balance in balances:
if remaining <= 0:
break
available = to_credit_decimal(balance.unspent_amount)
if available <= 0:
continue
allocated = min(available, remaining)
before_unspent = available
before_consumed = to_credit_decimal(balance.consumed_amount)
after_unspent = before_unspent - allocated
after_consumed = before_consumed + allocated
balance.unspent_amount = after_unspent
balance.consumed_amount = after_consumed
if after_unspent == 0:
balance.status = CreditBalanceStatus.CONSUMED.value
allocations_data.append(
(balance, allocated, before_unspent, after_unspent, before_consumed, after_consumed)
)
remaining -= allocated
if remaining > 0:
# 理论上用户级锁和前置汇总后不应发生;保留硬失败以避免部分扣除。
raise RuntimeError("积分分摊不足,事务将回滚")
meta_kwargs = await _record_meta_kwargs(db, user, record_meta)
after = before - consume_amount
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type=record_type,
amount=-consume_amount,
balance_delta=-consume_amount,
expired_amount=Decimal("0.00"),
balance_after=after,
description=description,
related_id=related_id,
request_time=checked_at,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
**meta_kwargs,
)
db.add(record)
await db.flush()
db.add_all(
[
CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=balance.id,
user_id=user_id,
allocation_action=CreditAllocationAction.CONSUME.value,
amount=allocated,
request_time=checked_at,
credit_level_snapshot=balance.credit_level,
source_type_snapshot=balance.source_type,
source_id_snapshot=balance.source_id,
valid_from_snapshot=balance.valid_from,
expires_at_snapshot=balance.expires_at,
unspent_before=before_unspent,
unspent_after=after_unspent,
consumed_before=before_consumed,
consumed_after=after_consumed,
)
for balance, allocated, before_unspent, after_unspent, before_consumed, after_consumed in allocations_data
]
)
attach_credit_snapshot(user, after)
await db.flush()
log_operation_event(
domain="billing",
module="dynamic_credit",
event_type="CREDIT_DEDUCTED",
event_status="success",
source="app.services.credit.ledger_service.deduct_credits",
user_id=user_id,
task_id=related_id,
message="动态积分同步扣除完成",
detail={
"record_id": record.id,
"amount": to_float(consume_amount),
"balance_before": to_float(before),
"balance_after": to_float(after),
"allocation_count": len(allocations_data),
"allocation_balance_ids": [item[0].id for item in allocations_data[:20]],
"biz_key": biz_key,
"request_time": checked_at.isoformat(),
},
)
return CreditMutationResult(
user=user,
record=record,
created=True,
amount=to_float(consume_amount),
balance_before=to_float(before),
balance_after=to_float(after),
)
async def _load_original_consume_allocations(
db: AsyncSession,
*,
original_record_id: str,
) -> list[_RefundAllocationSource]:
result = await db.execute(
select(CreditRecordAllocation)
.where(
CreditRecordAllocation.credit_record_id == original_record_id,
CreditRecordAllocation.allocation_action == CreditAllocationAction.CONSUME.value,
)
.order_by(CreditRecordAllocation.id.desc())
)
originals = list(result.scalars().all())
if not originals:
return []
original_ids = [item.id for item in originals]
transfer_result = await db.execute(
select(CreditRecordAllocation)
.where(
CreditRecordAllocation.source_allocation_id.in_(original_ids),
CreditRecordAllocation.allocation_action
== CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_IN.value,
)
.order_by(CreditRecordAllocation.id.asc())
)
transfers_by_source: dict[str, list[CreditRecordAllocation]] = {}
for item in transfer_result.scalars().all():
if item.source_allocation_id:
transfers_by_source.setdefault(item.source_allocation_id, []).append(item)
sources: list[_RefundAllocationSource] = []
for original in originals:
transferred = Decimal("0.00")
for transfer in transfers_by_source.get(original.id, []):
amount = to_credit_decimal(transfer.amount)
transferred += amount
sources.append(
_RefundAllocationSource(
original_allocation=original,
credit_balance_id=transfer.credit_balance_id,
amount=amount,
)
)
remaining = to_credit_decimal(original.amount) - transferred
if remaining < 0:
raise RuntimeError("升级积分来源迁移金额超过原消费分摊")
if remaining > 0:
sources.append(
_RefundAllocationSource(
original_allocation=original,
credit_balance_id=original.credit_balance_id,
amount=remaining,
)
)
return sources
async def refund_consumption(
db: AsyncSession,
*,
user_id: str,
refund_for_biz_key: str,
description: str,
related_id: str | None = None,
biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
refund_time: datetime | None = None,
) -> CreditRefundResult:
checked_at = refund_time or utc_now()
await acquire_user_credit_lock(db, user_id)
user = await _load_user(db, user_id)
before = await get_available_credits(db, user_id, request_time=checked_at)
existing_result = await db.execute(
select(CreditRecord).where(
CreditRecord.user_id == user_id,
CreditRecord.refund_for_biz_key == refund_for_biz_key,
CreditRecord.refund_kind.in_(["available", "expired"]),
)
)
existing_records = list(existing_result.scalars().all())
if existing_records:
available = sum((to_credit_decimal(item.balance_delta) for item in existing_records), Decimal("0.00"))
expired = sum((to_credit_decimal(item.expired_amount) for item in existing_records), Decimal("0.00"))
attach_credit_snapshot(user, before)
return CreditRefundResult(
records=tuple(existing_records),
created=False,
total_amount=available + expired,
available_amount=available,
expired_amount=expired,
balance_before=before,
balance_after=before,
)
original_result = await db.execute(
select(CreditRecord)
.where(CreditRecord.user_id == user_id, CreditRecord.biz_key == refund_for_biz_key)
.limit(1)
)
original = original_result.scalar_one_or_none()
if original is None:
raise ValueError("未找到原积分消费流水")
allocations = await _load_original_consume_allocations(db, original_record_id=original.id)
balance_ids = list(dict.fromkeys(item.credit_balance_id for item in allocations))
balances_result = await db.execute(
select(UserCreditBalance)
.where(UserCreditBalance.id.in_(balance_ids), UserCreditBalance.user_id == user_id)
.order_by(UserCreditBalance.id.asc())
.with_for_update()
)
balance_map = {item.id: item for item in balances_result.scalars().all()}
available_total = Decimal("0.00")
expired_total = Decimal("0.00")
available_allocations: list[tuple[CreditRecordAllocation, UserCreditBalance, Decimal, Decimal, Decimal, Decimal, Decimal]] = []
expired_allocations: list[tuple[CreditRecordAllocation, UserCreditBalance, Decimal, Decimal, Decimal, Decimal, Decimal]] = []
for allocation_source in allocations:
allocation = allocation_source.original_allocation
balance = balance_map.get(allocation_source.credit_balance_id)
if balance is None:
raise RuntimeError(f"原积分来源不存在: {allocation_source.credit_balance_id}")
refund_amount = to_credit_decimal(allocation_source.amount)
before_unspent = to_credit_decimal(balance.unspent_amount)
before_consumed = to_credit_decimal(balance.consumed_amount)
if before_consumed < refund_amount:
raise RuntimeError("原积分批次已消费金额不足以退款")
after_consumed = before_consumed - refund_amount
balance.consumed_amount = after_consumed
if checked_at < balance.expires_at and balance.revoked_at is None:
after_unspent = before_unspent + refund_amount
balance.unspent_amount = after_unspent
balance.status = CreditBalanceStatus.ACTIVE.value
available_total += refund_amount
available_allocations.append(
(allocation, balance, refund_amount, before_unspent, after_unspent, before_consumed, after_consumed)
)
else:
before_expired = to_credit_decimal(balance.expired_amount)
balance.expired_amount = before_expired + refund_amount
after_unspent = before_unspent
if balance.unspent_amount == 0 and balance.consumed_amount == 0:
balance.status = CreditBalanceStatus.EXPIRED.value
expired_total += refund_amount
expired_allocations.append(
(allocation, balance, refund_amount, before_unspent, after_unspent, before_consumed, after_consumed)
)
meta_kwargs = await _record_meta_kwargs(db, user, record_meta)
created_records: list[CreditRecord] = []
balance_after = before + available_total
async def create_refund_record(kind: str, amount: Decimal, expired_amount: Decimal) -> CreditRecord:
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type=CreditRecordType.REFUND.value,
amount=amount,
balance_delta=(amount if kind == "available" else Decimal("0.00")),
expired_amount=expired_amount,
balance_after=balance_after,
description=(
f"{description},退回有效积分:{to_float(amount)}"
if kind == "available"
else f"{description},原积分已过期:{to_float(expired_amount)}"
),
related_id=related_id,
request_time=checked_at,
biz_key=f"{biz_key or refund_for_biz_key + ':refund'}:{kind}",
refund_for_biz_key=refund_for_biz_key,
refund_kind=kind,
**meta_kwargs,
)
db.add(record)
await db.flush()
return record
if available_total > 0:
available_record = await create_refund_record("available", available_total, Decimal("0.00"))
created_records.append(available_record)
db.add_all(
[
CreditRecordAllocation(
id=generate_id(),
credit_record_id=available_record.id,
credit_balance_id=balance.id,
user_id=user_id,
source_allocation_id=allocation.id,
allocation_action=CreditAllocationAction.REFUND_AVAILABLE.value,
amount=amount,
request_time=checked_at,
credit_level_snapshot=balance.credit_level,
source_type_snapshot=balance.source_type,
source_id_snapshot=balance.source_id,
valid_from_snapshot=balance.valid_from,
expires_at_snapshot=balance.expires_at,
unspent_before=before_unspent,
unspent_after=after_unspent,
consumed_before=before_consumed,
consumed_after=after_consumed,
)
for allocation, balance, amount, before_unspent, after_unspent, before_consumed, after_consumed in available_allocations
]
)
if expired_total > 0:
expired_record = await create_refund_record("expired", expired_total, expired_total)
created_records.append(expired_record)
db.add_all(
[
CreditRecordAllocation(
id=generate_id(),
credit_record_id=expired_record.id,
credit_balance_id=balance.id,
user_id=user_id,
source_allocation_id=allocation.id,
allocation_action=CreditAllocationAction.REFUND_EXPIRED.value,
amount=amount,
request_time=checked_at,
credit_level_snapshot=balance.credit_level,
source_type_snapshot=balance.source_type,
source_id_snapshot=balance.source_id,
valid_from_snapshot=balance.valid_from,
expires_at_snapshot=balance.expires_at,
unspent_before=before_unspent,
unspent_after=after_unspent,
consumed_before=before_consumed,
consumed_after=after_consumed,
)
for allocation, balance, amount, before_unspent, after_unspent, before_consumed, after_consumed in expired_allocations
]
)
attach_credit_snapshot(user, balance_after)
await db.flush()
log_operation_event(
domain="billing",
module="dynamic_credit",
event_type="CREDIT_SOURCE_REFUNDED",
event_status="success",
source="app.services.credit.ledger_service.refund_consumption",
user_id=user_id,
task_id=related_id,
message="按原积分来源完成业务失败退款",
detail={
"refund_for_biz_key": refund_for_biz_key,
"available_refund": to_float(available_total),
"expired_refund": to_float(expired_total),
"source_count": len(allocations),
"created_record_ids": [item.id for item in created_records],
"refund_time": checked_at.isoformat(),
},
)
return CreditRefundResult(
records=tuple(created_records),
created=True,
total_amount=available_total + expired_total,
available_amount=available_total,
expired_amount=expired_total,
balance_before=before,
balance_after=balance_after,
)
async def revoke_balances(
db: AsyncSession,
*,
balances: Iterable[UserCreditBalance],
description: str,
related_id: str | None,
biz_key: str,
request_time: datetime | None = None,
) -> CreditMutationResult:
items = list(balances)
if not items:
raise ValueError("没有可撤销的积分")
checked_at = request_time or utc_now()
user_id = items[0].user_id
await acquire_user_credit_lock(db, user_id)
user = await _load_user(db, user_id)
before = await get_available_credits(db, user_id, request_time=checked_at)
existing = await _find_record_by_biz_key(db, user_id=user_id, biz_key=biz_key)
if existing:
attach_credit_snapshot(user, before)
return CreditMutationResult(user, existing, False, abs(to_float(existing.amount)), to_float(before), to_float(before))
total = Decimal("0.00")
allocation_rows: list[CreditRecordAllocation] = []
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type=CreditRecordType.REVOKE.value,
amount=Decimal("0.00"),
balance_delta=Decimal("0.00"),
expired_amount=Decimal("0.00"),
balance_after=before,
description=description,
related_id=related_id,
request_time=checked_at,
biz_key=biz_key,
billing_scene=CreditRecordBillingScene.CREDIT_REVOKE.value,
charge_action=CreditRecordAction.REFUND.value,
)
db.add(record)
await db.flush()
for balance in items:
if balance.user_id != user_id:
raise ValueError("不能跨用户撤销积分")
amount = to_credit_decimal(balance.unspent_amount)
if amount <= 0:
continue
before_unspent = amount
before_consumed = to_credit_decimal(balance.consumed_amount)
balance.unspent_amount = Decimal("0.00")
balance.revoked_amount = to_credit_decimal(balance.revoked_amount) + amount
balance.revoked_at = checked_at
balance.status = CreditBalanceStatus.REVOKED.value
total += amount
allocation_rows.append(
CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=balance.id,
user_id=user_id,
allocation_action=CreditAllocationAction.REVOKE.value,
amount=amount,
request_time=checked_at,
credit_level_snapshot=balance.credit_level,
source_type_snapshot=balance.source_type,
source_id_snapshot=balance.source_id,
valid_from_snapshot=balance.valid_from,
expires_at_snapshot=balance.expires_at,
unspent_before=before_unspent,
unspent_after=Decimal("0.00"),
consumed_before=before_consumed,
consumed_after=before_consumed,
)
)
after = before - total
record.amount = -total
record.balance_delta = -total
record.balance_after = after
db.add_all(allocation_rows)
attach_credit_snapshot(user, after)
await db.flush()
log_operation_event(
domain="billing",
module="dynamic_credit",
event_type="CREDIT_REVOKED",
event_status="success",
source="app.services.credit.ledger_service.revoke_balances",
user_id=user_id,
task_id=related_id,
message="积分批次撤销完成",
detail={
"record_id": record.id,
"amount": to_float(total),
"balance_count": len(allocation_rows),
"biz_key": biz_key,
},
)
return CreditMutationResult(user, record, True, to_float(total), to_float(before), to_float(after))
@@ -0,0 +1,15 @@
from __future__ import annotations
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
async def acquire_user_credit_lock(db: AsyncSession, user_id: str) -> None:
"""PostgreSQL 事务级用户锁;SQLite 调试环境无需额外锁。"""
bind = db.get_bind()
dialect_name = bind.dialect.name if bind is not None else ""
if dialect_name == "postgresql":
await db.execute(
text("SELECT pg_advisory_xact_lock(hashtextextended(:lock_key, 0))"),
{"lock_key": f"credit:{user_id}"},
)
@@ -0,0 +1,271 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_product import (
CreditProductType,
ProductPriceType,
SUBSCRIPTION_GRANT_COUNT,
SubscriptionBillingCycle,
)
from app.enums.credit_subscription import (
CreditSubscriptionPeriodStatus,
CreditSubscriptionStatus,
)
from app.models.credit.product import CreditProduct
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
from app.models.user import User
from app.services.credit.utils import to_credit_decimal, utc_now
@dataclass(slots=True, frozen=True)
class ProductPriceQuote:
product: CreditProduct
purchase_scene: str
price_type: str
base_price: Decimal
activity_price: Decimal | None
target_price: Decimal
deduction_amount: Decimal
payable_amount: Decimal
source_subscription_id: str | None = None
upgrade_period_ids: tuple[str, ...] = ()
def grant_count_for_cycle(cycle: str | None) -> int:
try:
return SUBSCRIPTION_GRANT_COUNT[str(cycle)]
except KeyError as exc:
raise ValueError("不支持的订阅周期") from exc
def activity_price_if_valid(product: CreditProduct, request_time: datetime) -> Decimal | None:
if product.activity_price is None:
return None
if product.activity_start_at is None or product.activity_end_at is None:
return None
if not (product.activity_start_at <= request_time < product.activity_end_at):
return None
return to_credit_decimal(product.activity_price)
def current_product_price(
product: CreditProduct,
*,
first_purchase: bool,
request_time: datetime,
upgrade: bool = False,
) -> tuple[Decimal, Decimal | None, Decimal, str]:
if product.product_type == CreditProductType.CREDIT_ADDON.value:
price = to_credit_decimal(product.price)
return price, None, price, ProductPriceType.REGULAR.value
base = to_credit_decimal(
product.regular_price if upgrade or not first_purchase else product.first_purchase_price
)
activity = activity_price_if_valid(product, request_time)
if activity is not None and activity < base:
return base, activity, activity, ProductPriceType.ACTIVITY.value
return base, activity, base, (
ProductPriceType.UPGRADE.value
if upgrade
else ProductPriceType.FIRST_PURCHASE.value if first_purchase else ProductPriceType.REGULAR.value
)
async def get_active_subscription(
db: AsyncSession,
user_id: str,
*,
request_time: datetime | None = None,
for_update: bool = False,
) -> UserCreditSubscription | None:
checked_at = request_time or utc_now()
stmt = (
select(UserCreditSubscription)
.where(
UserCreditSubscription.user_id == user_id,
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= checked_at,
UserCreditSubscription.expires_at > checked_at,
)
.order_by(UserCreditSubscription.start_at.desc(), UserCreditSubscription.id.desc())
.limit(1)
)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def get_upgrade_deduction_preview(
db: AsyncSession,
*,
subscription: UserCreditSubscription,
request_time: datetime,
) -> Decimal:
if subscription.billing_cycle not in {
SubscriptionBillingCycle.QUARTERLY.value,
SubscriptionBillingCycle.YEARLY.value,
}:
return Decimal("0.00")
result = await db.execute(
select(UserCreditSubscriptionPeriod.allocated_paid_amount).where(
UserCreditSubscriptionPeriod.subscription_id == subscription.id,
UserCreditSubscriptionPeriod.scheduled_at > request_time,
UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.SCHEDULED.value,
)
)
return sum((to_credit_decimal(value) for value in result.scalars().all()), Decimal("0.00"))
async def list_active_products(db: AsyncSession) -> list[CreditProduct]:
result = await db.execute(
select(CreditProduct)
.where(CreditProduct.is_active.is_(True))
.order_by(CreditProduct.product_type.asc(), CreditProduct.sort_order.asc(), CreditProduct.id.asc())
)
return list(result.scalars().all())
async def get_product(db: AsyncSession, product_id: str, *, for_update: bool = False) -> CreditProduct | None:
stmt = select(CreditProduct).where(CreditProduct.id == product_id).limit(1)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def build_product_catalog(
db: AsyncSession,
*,
user: User,
request_time: datetime | None = None,
) -> dict:
checked_at = request_time or utc_now()
products = await list_active_products(db)
current = await get_active_subscription(db, user.id, request_time=checked_at)
first_purchase = user.first_membership_paid_at is None
subscription_products: list[dict] = []
credit_addons: list[dict] = []
upgrade_deduction = (
await get_upgrade_deduction_preview(db, subscription=current, request_time=checked_at)
if current is not None
else Decimal("0.00")
)
for product in products:
if product.product_type == CreditProductType.CREDIT_ADDON.value:
credit_addons.append(product_to_dict(product, user_price=to_credit_decimal(product.price), price_type=ProductPriceType.REGULAR.value, can_purchase=True))
continue
# 首订资格已经使用后,未开启续费的套餐不返回给客户端。
# 该过滤同时适用于过期后的续费和有效订阅期间的升级入口,
# 避免仅靠客户端隐藏后仍可被直接构造请求购买。
if not first_purchase and not bool(product.renewal_enabled):
continue
can_purchase = current is None
can_upgrade = False
reason = None
if current is not None:
can_upgrade = (
product.billing_cycle == current.billing_cycle
and int(product.tier_rank or 0) > int(current.tier_rank or 0)
)
can_purchase = can_upgrade
if not can_upgrade:
reason = "当前订阅有效,暂不能续费;仅可升级同周期更高等级套餐"
_, _, target_price, price_type = current_product_price(
product,
first_purchase=first_purchase,
request_time=checked_at,
upgrade=can_upgrade,
)
deduction_amount = upgrade_deduction if can_upgrade else Decimal("0.00")
user_price = max(Decimal("0.00"), target_price - deduction_amount)
if can_upgrade and user_price <= Decimal("0.00"):
can_purchase = False
reason = "当前升级抵扣金额已达到或超过目标套餐价格,暂不支持0元升级,请联系客服处理"
item = product_to_dict(
product,
user_price=user_price,
price_type=price_type,
can_purchase=can_purchase,
target_price=target_price,
deduction_amount=deduction_amount,
)
item["can_upgrade"] = can_upgrade
item["unavailable_reason"] = reason
subscription_products.append(item)
return {
"subscription_products": subscription_products,
"credit_addons": credit_addons,
"first_purchase_available": first_purchase,
"current_subscription": subscription_to_dict(current) if current else None,
}
def product_to_dict(
product: CreditProduct,
*,
user_price: Decimal | None = None,
price_type: str | None = None,
can_purchase: bool | None = None,
target_price: Decimal | None = None,
deduction_amount: Decimal | None = None,
) -> dict:
return {
"id": product.id,
"product_code": product.product_code,
"product_type": product.product_type,
"name": product.name,
"description": product.description,
"features": product.features_json or [],
"tier_code": product.tier_code,
"tier_rank": product.tier_rank,
"billing_cycle": product.billing_cycle,
"monthly_grant_credits": float(product.monthly_grant_credits or 0),
"grant_count": grant_count_for_cycle(product.billing_cycle) if product.is_subscription else 1,
"first_purchase_price": float(product.first_purchase_price or 0),
"regular_price": float(product.regular_price or 0),
"activity_price": float(product.activity_price) if product.activity_price is not None else None,
"activity_start_at": product.activity_start_at,
"activity_end_at": product.activity_end_at,
"renewal_enabled": bool(product.renewal_enabled),
"grant_credits": float(product.grant_credits or 0),
"validity_months": int(product.validity_months or 1) if product.is_credit_addon else None,
"price": float(user_price if user_price is not None else product.price),
"current_price": float(user_price if user_price is not None else product.price),
"target_price": float(target_price) if target_price is not None else None,
"deduction_amount": float(deduction_amount or Decimal("0.00")),
"price_type": price_type,
"credit_level": product.credit_level,
"currency": product.currency,
"is_active": product.is_active,
"sort_order": product.sort_order,
"can_purchase": can_purchase,
}
def subscription_to_dict(subscription: UserCreditSubscription) -> dict:
return {
"id": subscription.id,
"product_id": subscription.product_id,
"status": subscription.status,
"purchase_scene": subscription.purchase_scene,
"tier_code": subscription.tier_code,
"tier_rank": subscription.tier_rank,
"billing_cycle": subscription.billing_cycle,
"anchor_at": subscription.anchor_at,
"start_at": subscription.start_at,
"expires_at": subscription.expires_at,
"monthly_grant_credits": float(subscription.monthly_grant_credits_snapshot),
"grant_count": subscription.grant_count,
"granted_count": subscription.granted_count,
}
@@ -0,0 +1,170 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from typing import Iterable
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_balance import CreditBalanceStatus
from app.models.credit.balance import UserCreditBalance
from app.services.credit.time_policy import last_usable_at
from app.services.credit.utils import to_credit_decimal, to_float, utc_now
@dataclass(slots=True, frozen=True)
class CreditBalanceSummary:
available_credits: Decimal
next_expiring_credits: Decimal
next_expires_at: datetime | None
next_last_usable_at: datetime | None
def to_dict(self) -> dict:
return {
"available_credits": to_float(self.available_credits),
"credits": to_float(self.available_credits),
"next_expiring_credits": to_float(self.next_expiring_credits),
"next_expires_at": self.next_expires_at,
"next_last_usable_at": self.next_last_usable_at,
}
async def get_available_credits(
db: AsyncSession,
user_id: str,
*,
request_time: datetime | None = None,
) -> Decimal:
checked_at = request_time or utc_now()
result = await db.execute(
select(func.coalesce(func.sum(UserCreditBalance.unspent_amount), 0)).where(
UserCreditBalance.user_id == user_id,
UserCreditBalance.valid_from <= checked_at,
UserCreditBalance.expires_at > checked_at,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.revoked_at.is_(None),
)
)
return to_credit_decimal(result.scalar_one())
async def get_balance_summary(
db: AsyncSession,
user_id: str,
*,
request_time: datetime | None = None,
) -> CreditBalanceSummary:
checked_at = request_time or utc_now()
available = await get_available_credits(db, user_id, request_time=checked_at)
expiry_result = await db.execute(
select(
UserCreditBalance.expires_at,
func.sum(UserCreditBalance.unspent_amount).label("amount"),
)
.where(
UserCreditBalance.user_id == user_id,
UserCreditBalance.valid_from <= checked_at,
UserCreditBalance.expires_at > checked_at,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.revoked_at.is_(None),
)
.group_by(UserCreditBalance.expires_at)
.order_by(UserCreditBalance.expires_at.asc())
.limit(1)
)
row = expiry_result.first()
expires_at = row.expires_at if row else None
expiring = to_credit_decimal(row.amount if row else 0)
return CreditBalanceSummary(
available_credits=available,
next_expiring_credits=expiring,
next_expires_at=expires_at,
next_last_usable_at=last_usable_at(expires_at) if expires_at else None,
)
async def get_user_credit_map(
db: AsyncSession,
user_ids: Iterable[str],
*,
request_time: datetime | None = None,
) -> dict[str, float]:
ids = list(dict.fromkeys(str(item) for item in user_ids if item))
if not ids:
return {}
checked_at = request_time or utc_now()
result = await db.execute(
select(
UserCreditBalance.user_id,
func.coalesce(func.sum(UserCreditBalance.unspent_amount), 0).label("credits"),
)
.where(
UserCreditBalance.user_id.in_(ids),
UserCreditBalance.valid_from <= checked_at,
UserCreditBalance.expires_at > checked_at,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.revoked_at.is_(None),
)
.group_by(UserCreditBalance.user_id)
)
output = {user_id: 0.0 for user_id in ids}
for row in result:
output[str(row.user_id)] = to_float(row.credits)
return output
def attach_credit_snapshot(user: object, credits: Decimal | float | int) -> object:
# SQLAlchemy Declarative 对象允许附加非映射运行时属性;不会写回 users 表。
setattr(user, "credits", to_float(to_credit_decimal(credits)))
return user
def effective_balance_status(balance: UserCreditBalance, *, request_time: datetime | None = None) -> str:
checked_at = request_time or utc_now()
if balance.revoked_at is not None or to_credit_decimal(balance.revoked_amount) > 0:
return CreditBalanceStatus.REVOKED.value
if balance.expires_at <= checked_at:
return CreditBalanceStatus.EXPIRED.value
if balance.valid_from > checked_at:
return CreditBalanceStatus.SCHEDULED.value
if to_credit_decimal(balance.unspent_amount) <= 0:
return CreditBalanceStatus.CONSUMED.value
return CreditBalanceStatus.ACTIVE.value
def apply_balance_status_filter(stmt, status: str | None, *, request_time: datetime):
if not status:
return stmt
if status == CreditBalanceStatus.REVOKED.value:
return stmt.where(
or_(UserCreditBalance.revoked_at.is_not(None), UserCreditBalance.revoked_amount > 0)
)
base_not_revoked = and_(
UserCreditBalance.revoked_at.is_(None),
UserCreditBalance.revoked_amount <= 0,
)
if status == CreditBalanceStatus.EXPIRED.value:
return stmt.where(base_not_revoked, UserCreditBalance.expires_at <= request_time)
if status == CreditBalanceStatus.SCHEDULED.value:
return stmt.where(
base_not_revoked,
UserCreditBalance.valid_from > request_time,
UserCreditBalance.expires_at > request_time,
)
if status == CreditBalanceStatus.CONSUMED.value:
return stmt.where(
base_not_revoked,
UserCreditBalance.valid_from <= request_time,
UserCreditBalance.expires_at > request_time,
UserCreditBalance.unspent_amount <= 0,
)
if status == CreditBalanceStatus.ACTIVE.value:
return stmt.where(
base_not_revoked,
UserCreditBalance.valid_from <= request_time,
UserCreditBalance.expires_at > request_time,
UserCreditBalance.unspent_amount > 0,
)
return stmt.where(UserCreditBalance.status == status)
@@ -0,0 +1,827 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_balance import (
CreditAllocationAction,
CreditBalanceSourceType,
CreditBalanceStatus,
)
from app.enums.credit_product import CreditProductType
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordType
from app.enums.credit_subscription import (
CreditSubscriptionPeriodStatus,
CreditSubscriptionStatus,
)
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit.balance import UserCreditBalance
from app.models.credit.product import CreditProduct
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
from app.models.credit_record import CreditRecord
from app.models.payment_order import PaymentOrder
from app.models.user import User
from app.services.credit.ledger_service import grant_credits
from app.services.credit.locking import acquire_user_credit_lock
from app.services.credit.product_service import grant_count_for_cycle
from app.services.credit.query_service import get_available_credits
from app.services.credit.time_policy import add_natural_months, natural_month_period
from app.services.credit.utils import to_credit_decimal, utc_now
from app.services.operation_log_service import log_operation_event
from app.utils.id_gen import generate_id
def _allocate_amount_by_period(total: Decimal, count: int) -> list[Decimal]:
cents = int((to_credit_decimal(total) * 100).to_integral_value())
base, remainder = divmod(cents, count)
values = [Decimal(base) / 100 for _ in range(count)]
values[-1] += Decimal(remainder) / 100
return [to_credit_decimal(item) for item in values]
def _product_snapshot(product: CreditProduct) -> dict:
return {
"id": product.id,
"product_code": product.product_code,
"product_type": product.product_type,
"name": product.name,
"tier_code": product.tier_code,
"tier_rank": product.tier_rank,
"billing_cycle": product.billing_cycle,
"monthly_grant_credits": float(product.monthly_grant_credits or 0),
"first_purchase_price": float(product.first_purchase_price or 0),
"regular_price": float(product.regular_price or 0),
"activity_price": float(product.activity_price) if product.activity_price is not None else None,
"grant_credits": float(product.grant_credits or 0),
"validity_months": int(product.validity_months or 1) if product.is_credit_addon else None,
"credit_level": product.credit_level,
"features": product.features_json or [],
}
def _resolve_order_product_snapshot(
order: PaymentOrder,
product: CreditProduct | None,
) -> dict:
"""返回支付创建时冻结的商品快照。
支付履约不得读取后台后来修改后的价格、积分或套餐周期。旧订单若尚未
保存快照,才允许使用当前商品生成一次兼容快照。
"""
snapshot = dict(order.product_snapshot_json or {})
if not snapshot and product is not None:
snapshot = _product_snapshot(product)
if not snapshot:
raise ValueError("订单缺少商品快照,无法履约")
return snapshot
def _snapshot_decimal(snapshot: dict, key: str) -> Decimal:
return to_credit_decimal(snapshot.get(key) or 0)
def _snapshot_validity_months(snapshot: dict) -> int:
raw_value = snapshot.get("validity_months")
if raw_value is None:
# 兼容历史订单快照:旧版本增值包固定为1个自然月。
return 1
if isinstance(raw_value, bool):
raise ValueError("积分增值包有效期快照无效")
try:
months = int(raw_value)
except (TypeError, ValueError) as exc:
raise ValueError("积分增值包有效期快照无效") from exc
if isinstance(raw_value, float) and not raw_value.is_integer():
raise ValueError("积分增值包有效期快照无效")
if not 1 <= months <= 36:
raise ValueError("积分增值包有效期必须为1-36个月")
return months
async def _create_subscription(
db: AsyncSession,
*,
order: PaymentOrder,
product: CreditProduct | None,
paid_at: datetime,
) -> tuple[UserCreditSubscription, list[UserCreditSubscriptionPeriod]]:
snapshot = _resolve_order_product_snapshot(order, product)
billing_cycle = str(snapshot.get("billing_cycle") or "")
count = grant_count_for_cycle(billing_cycle)
expires_at = add_natural_months(paid_at, count)
# 升级订单的现金实付额已扣除旧套餐未来周期价值;新订阅后续再升级时,
# 周期价值必须按目标套餐完整价格快照分摊,不能只按本次现金实付额分摊。
pricing_basis_amount = to_credit_decimal(
order.target_price_snapshot
if order.purchase_scene == "upgrade" and order.target_price_snapshot is not None
else order.amount
)
subscription = UserCreditSubscription(
id=generate_id(),
user_id=order.user_id,
product_id=order.product_id,
payment_order_id=order.id,
status=CreditSubscriptionStatus.ACTIVE.value,
purchase_scene=str(order.purchase_scene or "renewal"),
tier_code=str(snapshot.get("tier_code") or ""),
tier_rank=int(snapshot.get("tier_rank") or 0),
billing_cycle=billing_cycle,
anchor_at=paid_at,
start_at=paid_at,
expires_at=expires_at,
next_grant_at=(add_natural_months(paid_at, 1) if count > 1 else None),
monthly_grant_credits_snapshot=_snapshot_decimal(snapshot, "monthly_grant_credits"),
grant_count=count,
granted_count=0,
paid_amount_snapshot=pricing_basis_amount,
product_snapshot_json=snapshot,
source_subscription_id=order.source_subscription_id,
upgrade_order_id=(order.id if order.purchase_scene == "upgrade" else None),
)
db.add(subscription)
await db.flush()
allocations = _allocate_amount_by_period(pricing_basis_amount, count)
periods: list[UserCreditSubscriptionPeriod] = []
for sequence in range(count):
start, end = natural_month_period(paid_at, sequence)
period = UserCreditSubscriptionPeriod(
id=generate_id(),
subscription_id=subscription.id,
sequence=sequence + 1,
scheduled_at=start,
valid_from=start,
expires_at=end,
grant_credits=_snapshot_decimal(snapshot, "monthly_grant_credits"),
allocated_paid_amount=allocations[sequence],
status=CreditSubscriptionPeriodStatus.SCHEDULED.value,
)
db.add(period)
periods.append(period)
await db.flush()
return subscription, periods
async def grant_subscription_period(
db: AsyncSession,
*,
subscription: UserCreditSubscription,
period: UserCreditSubscriptionPeriod,
request_time: datetime | None = None,
) -> UserCreditBalance:
checked_at = request_time or utc_now()
if period.status == CreditSubscriptionPeriodStatus.GRANTED.value and period.issued_balance_id:
result = await db.execute(
select(UserCreditBalance).where(UserCreditBalance.id == period.issued_balance_id).limit(1)
)
existing = result.scalar_one_or_none()
if existing:
return existing
if period.status not in {
CreditSubscriptionPeriodStatus.SCHEDULED.value,
CreditSubscriptionPeriodStatus.GRANTED.value,
}:
raise ValueError("当前订阅周期不能发放积分")
mutation = await grant_credits(
db,
user_id=subscription.user_id,
amount=period.grant_credits,
description=f"订阅套餐第{period.sequence}个月积分发放",
source_type=CreditBalanceSourceType.SUBSCRIPTION_GRANT.value,
valid_from=period.valid_from,
expires_at=period.expires_at,
credit_level=str(subscription.product_snapshot_json.get("credit_level") or "general"),
source_id=period.id,
product_id=subscription.product_id,
payment_order_id=subscription.payment_order_id,
subscription_id=subscription.id,
subscription_period_id=period.id,
related_id=subscription.id,
record_type=CreditRecordType.RECHARGE.value,
biz_key=f"subscription:{subscription.id}:period:{period.sequence}:grant",
metadata_json={"period_sequence": period.sequence},
request_time=checked_at,
)
result = await db.execute(
select(UserCreditBalance)
.where(UserCreditBalance.grant_record_id == mutation.record.id)
.limit(1)
)
balance = result.scalar_one()
period.status = CreditSubscriptionPeriodStatus.GRANTED.value
period.issued_balance_id = balance.id
period.issued_at = checked_at
subscription.granted_count = max(subscription.granted_count, period.sequence)
subscription.next_grant_at = (
add_natural_months(subscription.anchor_at, period.sequence)
if period.sequence < subscription.grant_count
else None
)
await db.flush()
return balance
async def _reconcile_upgrade_period_mismatch(
db: AsyncSession,
*,
order: PaymentOrder,
new_first_balance: UserCreditBalance,
checked_at: datetime,
) -> None:
"""核对升级订单创建时的抵扣周期与支付结算时状态。
正常情况下抵扣周期一直处于 upgrade_reserved,仅取消未来发放。
若历史竞态导致周期已发放,则撤销未消费积分,并把每一笔尚未退款的
消费分摊通过 source_allocation_id 迁移到升级套餐首期积分,保证后续
业务失败退款仍能准确退回当前实际承担成本的积分来源。
"""
period_ids = list(order.upgrade_period_ids_json or [])
if not period_ids:
return
result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(UserCreditSubscriptionPeriod.id.in_(period_ids))
.order_by(UserCreditSubscriptionPeriod.id.asc())
.with_for_update()
)
periods = list(result.scalars().all())
expected_ids = [str(item) for item in period_ids]
if len(expected_ids) != len(set(expected_ids)):
raise RuntimeError("升级订单抵扣周期快照存在重复ID")
actual_ids = {str(period.id) for period in periods}
if actual_ids != set(expected_ids):
raise RuntimeError("升级订单抵扣周期快照与实际周期集合不一致")
if not order.source_subscription_id:
raise RuntimeError("升级订单缺少原订阅ID")
allowed_statuses = {
CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value,
CreditSubscriptionPeriodStatus.GRANTED.value,
}
for period in periods:
if str(period.subscription_id) != str(order.source_subscription_id):
raise RuntimeError("升级订单抵扣周期不属于原订阅")
if str(period.upgrade_order_id or "") != str(order.id):
raise RuntimeError("升级订单抵扣周期未绑定当前订单")
if period.status not in allowed_statuses:
raise RuntimeError(f"升级订单抵扣周期状态不允许结算:{period.status}")
if period.status == CreditSubscriptionPeriodStatus.GRANTED.value and not period.issued_balance_id:
raise RuntimeError("升级订单抵扣周期已发放但缺少积分来源记录")
if period.status == CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value and period.issued_balance_id:
raise RuntimeError("升级订单抵扣周期仍为预留状态但已存在积分来源记录")
reserved = [
period for period in periods
if period.status == CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value
]
inconsistent = [
period
for period in periods
if period.status == CreditSubscriptionPeriodStatus.GRANTED.value
and period.issued_balance_id
]
for period in reserved:
period.status = CreditSubscriptionPeriodStatus.CANCELLED_BY_UPGRADE.value
period.cancelled_at = checked_at
if not inconsistent:
return
balance_ids = [str(period.issued_balance_id) for period in inconsistent if period.issued_balance_id]
balance_result = await db.execute(
select(UserCreditBalance)
.where(UserCreditBalance.id.in_(balance_ids))
.order_by(UserCreditBalance.id.asc())
.with_for_update()
)
old_balances = list(balance_result.scalars().all())
if {str(item.id) for item in old_balances} != set(balance_ids):
raise RuntimeError("升级边界补偿缺少原积分批次")
period_by_balance = {str(period.issued_balance_id): period for period in inconsistent}
for balance in old_balances:
period = period_by_balance.get(str(balance.id))
if period is None:
raise RuntimeError("升级边界补偿积分批次与周期无法对应")
if str(balance.user_id) != str(order.user_id):
raise RuntimeError("升级边界补偿积分批次不属于当前用户")
if str(balance.subscription_id or "") != str(order.source_subscription_id):
raise RuntimeError("升级边界补偿积分批次不属于原订阅")
if str(balance.subscription_period_id or "") != str(period.id):
raise RuntimeError("升级边界补偿积分批次与订阅周期不一致")
total_transfer = sum(
(to_credit_decimal(item.consumed_amount) for item in old_balances),
Decimal("0.00"),
)
if to_credit_decimal(new_first_balance.unspent_amount) < total_transfer:
raise RuntimeError("升级套餐首期积分不足以承接边界消费来源迁移")
before_available = await get_available_credits(db, order.user_id, request_time=checked_at)
record = CreditRecord(
id=generate_id(),
user_id=order.user_id,
type=CreditRecordType.REVOKE.value,
amount=Decimal("0.00"),
balance_delta=Decimal("0.00"),
expired_amount=Decimal("0.00"),
balance_after=before_available,
description="套餐升级边界核对:废弃重复发放积分并迁移已消费来源",
related_id=order.id,
request_time=checked_at,
biz_key=f"payment-order:{order.id}:upgrade-reconcile",
billing_scene=CreditRecordBillingScene.CREDIT_REVOKE.value,
)
db.add(record)
await db.flush()
new_unspent_cursor = to_credit_decimal(new_first_balance.unspent_amount)
new_consumed_cursor = to_credit_decimal(new_first_balance.consumed_amount)
total_removed = Decimal("0.00")
for old in old_balances:
old_unspent = to_credit_decimal(old.unspent_amount)
old_consumed = to_credit_decimal(old.consumed_amount)
total_removed += old_unspent + old_consumed
if old_unspent > 0:
db.add(
CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=old.id,
user_id=order.user_id,
allocation_action=CreditAllocationAction.REVOKE.value,
amount=old_unspent,
request_time=checked_at,
credit_level_snapshot=old.credit_level,
source_type_snapshot=old.source_type,
source_id_snapshot=old.source_id,
valid_from_snapshot=old.valid_from,
expires_at_snapshot=old.expires_at,
unspent_before=old_unspent,
unspent_after=Decimal("0.00"),
consumed_before=old_consumed,
consumed_after=old_consumed,
)
)
if old_consumed > 0:
consume_result = await db.execute(
select(CreditRecordAllocation)
.where(
CreditRecordAllocation.credit_balance_id == old.id,
CreditRecordAllocation.allocation_action
== CreditAllocationAction.CONSUME.value,
)
.order_by(CreditRecordAllocation.created_at.asc(), CreditRecordAllocation.id.asc())
)
original_allocations = list(consume_result.scalars().all())
original_ids = [item.id for item in original_allocations]
adjusted_by_source: dict[str, Decimal] = {}
if original_ids:
adjusted_result = await db.execute(
select(CreditRecordAllocation).where(
CreditRecordAllocation.source_allocation_id.in_(original_ids),
CreditRecordAllocation.allocation_action.in_(
[
CreditAllocationAction.REFUND_AVAILABLE.value,
CreditAllocationAction.REFUND_EXPIRED.value,
CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_OUT.value,
]
),
)
)
for adjusted in adjusted_result.scalars().all():
if adjusted.source_allocation_id:
adjusted_by_source[adjusted.source_allocation_id] = (
adjusted_by_source.get(adjusted.source_allocation_id, Decimal("0.00"))
+ to_credit_decimal(adjusted.amount)
)
remaining = old_consumed
old_consumed_cursor = old_consumed
for original_allocation in original_allocations:
if remaining <= 0:
break
original_amount = to_credit_decimal(original_allocation.amount)
already_adjusted = adjusted_by_source.get(original_allocation.id, Decimal("0.00"))
available_to_transfer = original_amount - already_adjusted
if available_to_transfer < 0:
raise RuntimeError("消费分摊的退款或迁移金额超过原消费金额")
transfer_amount = min(available_to_transfer, remaining)
if transfer_amount <= 0:
continue
db.add(
CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=old.id,
user_id=order.user_id,
source_allocation_id=original_allocation.id,
allocation_action=CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_OUT.value,
amount=transfer_amount,
request_time=checked_at,
credit_level_snapshot=old.credit_level,
source_type_snapshot=old.source_type,
source_id_snapshot=old.source_id,
valid_from_snapshot=old.valid_from,
expires_at_snapshot=old.expires_at,
unspent_before=Decimal("0.00"),
unspent_after=Decimal("0.00"),
consumed_before=old_consumed_cursor,
consumed_after=old_consumed_cursor - transfer_amount,
)
)
db.add(
CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=new_first_balance.id,
user_id=order.user_id,
source_allocation_id=original_allocation.id,
allocation_action=CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_IN.value,
amount=transfer_amount,
request_time=checked_at,
credit_level_snapshot=new_first_balance.credit_level,
source_type_snapshot=new_first_balance.source_type,
source_id_snapshot=new_first_balance.source_id,
valid_from_snapshot=new_first_balance.valid_from,
expires_at_snapshot=new_first_balance.expires_at,
unspent_before=new_unspent_cursor,
unspent_after=new_unspent_cursor - transfer_amount,
consumed_before=new_consumed_cursor,
consumed_after=new_consumed_cursor + transfer_amount,
)
)
old_consumed_cursor -= transfer_amount
new_unspent_cursor -= transfer_amount
new_consumed_cursor += transfer_amount
remaining -= transfer_amount
if remaining > 0:
raise RuntimeError("无法定位全部已消费积分的原始业务分摊,升级结算已中止")
old.unspent_amount = Decimal("0.00")
old.consumed_amount = Decimal("0.00")
old.revoked_amount = to_credit_decimal(old.revoked_amount) + old_unspent + old_consumed
old.revoked_at = checked_at
old.status = CreditBalanceStatus.REVOKED.value
for period in inconsistent:
if period.issued_balance_id == old.id:
period.status = CreditSubscriptionPeriodStatus.REVOKED_BY_UPGRADE.value
period.revoked_at = checked_at
new_first_balance.unspent_amount = new_unspent_cursor
new_first_balance.consumed_amount = new_consumed_cursor
if new_unspent_cursor == 0:
new_first_balance.status = CreditBalanceStatus.CONSUMED.value
record.amount = -total_removed
record.balance_delta = -total_removed
record.balance_after = before_available - total_removed
await db.flush()
async def fulfill_payment_product(
db: AsyncSession,
*,
order: PaymentOrder,
fulfilled_at: datetime | None = None,
) -> None:
if order.fulfillment_status == "fulfilled":
return
checked_at = fulfilled_at or order.paid_at or utc_now()
await acquire_user_credit_lock(db, order.user_id)
user_result = await db.execute(select(User).where(User.id == order.user_id).with_for_update().limit(1))
user = user_result.scalar_one()
product_result = await db.execute(
select(CreditProduct).where(CreditProduct.id == order.product_id).limit(1)
)
product = product_result.scalar_one_or_none()
snapshot = _resolve_order_product_snapshot(order, product)
product_type = str(order.product_type or snapshot.get("product_type") or "")
if product_type == CreditProductType.CREDIT_ADDON.value:
await grant_credits(
db,
user_id=order.user_id,
amount=_snapshot_decimal(snapshot, "grant_credits"),
description=f"购买积分增值包:{order.product_name_snapshot or snapshot.get('name') or '积分增值包'}",
source_type=CreditBalanceSourceType.CREDIT_ADDON.value,
valid_from=checked_at,
expires_at=add_natural_months(checked_at, _snapshot_validity_months(snapshot)),
credit_level=str(snapshot.get("credit_level") or "general"),
source_id=order.id,
product_id=order.product_id,
payment_order_id=order.id,
related_id=order.id,
biz_key=f"payment-order:{order.id}:credit-addon:grant",
request_time=checked_at,
)
order.fulfillment_status = "fulfilled"
order.fulfilled_at = checked_at
return
old_subscription: UserCreditSubscription | None = None
if order.purchase_scene == "upgrade":
if not order.source_subscription_id:
order.fulfillment_status = CreditSubscriptionStatus.UPGRADE_RECONCILE_FAILED.value
await db.flush()
return
old_result = await db.execute(
select(UserCreditSubscription)
.where(UserCreditSubscription.id == order.source_subscription_id)
.with_for_update()
.limit(1)
)
old_subscription = old_result.scalar_one_or_none()
if old_subscription is None or str(old_subscription.user_id) != str(order.user_id):
order.fulfillment_status = CreditSubscriptionStatus.UPGRADE_RECONCILE_FAILED.value
await db.flush()
return
if order.purchase_scene != "upgrade":
# 首充/续费维持原有支付履约语义:创建或发放异常继续向外抛出,
# 不能被升级专用的核对失败状态吞掉。
subscription, periods = await _create_subscription(
db,
order=order,
product=product,
paid_at=checked_at,
)
await grant_subscription_period(
db,
subscription=subscription,
period=periods[0],
request_time=checked_at,
)
else:
try:
async with db.begin_nested():
subscription, periods = await _create_subscription(
db,
order=order,
product=product,
paid_at=checked_at,
)
first_balance = await grant_subscription_period(
db,
subscription=subscription,
period=periods[0],
request_time=checked_at,
)
await _reconcile_upgrade_period_mismatch(
db,
order=order,
new_first_balance=first_balance,
checked_at=checked_at,
)
except Exception as exc:
order.fulfillment_status = CreditSubscriptionStatus.UPGRADE_RECONCILE_FAILED.value
await db.flush()
log_operation_event(
domain="billing",
module="credit_upgrade",
event_type="CREDIT_UPGRADE_RECONCILE_FAILED",
event_status="failed",
source="app.services.credit.subscription_service.fulfill_payment_product",
user_id=str(order.user_id),
task_id=str(order.id),
message="套餐升级支付履约周期核对失败",
error=str(exc),
detail={
"order_id": str(order.id),
"order_no": str(order.order_no),
"source_subscription_id": str(order.source_subscription_id or ""),
"upgrade_period_ids": list(order.upgrade_period_ids_json or []),
},
)
return
if old_subscription is not None:
old_subscription.status = CreditSubscriptionStatus.UPGRADED.value
old_subscription.upgrade_order_id = order.id
order.subscription_id = subscription.id
order.fulfillment_status = "fulfilled"
order.fulfilled_at = checked_at
if user.first_membership_paid_at is None:
user.first_membership_paid_at = checked_at
await db.flush()
async def list_due_subscription_period_candidates(
db: AsyncSession,
*,
request_time: datetime,
batch_size: int = 100,
) -> list[tuple[str, str, str]]:
result = await db.execute(
select(
UserCreditSubscriptionPeriod.id,
UserCreditSubscriptionPeriod.subscription_id,
UserCreditSubscription.user_id,
)
.join(
UserCreditSubscription,
UserCreditSubscription.id == UserCreditSubscriptionPeriod.subscription_id,
)
.where(
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.SCHEDULED.value,
UserCreditSubscriptionPeriod.scheduled_at <= request_time,
)
.order_by(UserCreditSubscriptionPeriod.scheduled_at.asc(), UserCreditSubscriptionPeriod.id.asc())
.limit(max(1, batch_size))
)
return [(str(row.id), str(row.subscription_id), str(row.user_id)) for row in result.all()]
async def grant_due_subscription_period_by_id(
db: AsyncSession,
*,
period_id: str,
subscription_id: str,
user_id: str,
request_time: datetime,
) -> bool:
await acquire_user_credit_lock(db, user_id)
subscription_result = await db.execute(
select(UserCreditSubscription)
.where(UserCreditSubscription.id == subscription_id)
.with_for_update()
.limit(1)
)
subscription = subscription_result.scalar_one_or_none()
if subscription is None or subscription.status != CreditSubscriptionStatus.ACTIVE.value:
return False
period_result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(UserCreditSubscriptionPeriod.id == period_id)
.with_for_update()
.limit(1)
)
period = period_result.scalar_one_or_none()
if (
period is None
or period.subscription_id != subscription.id
or period.status != CreditSubscriptionPeriodStatus.SCHEDULED.value
or period.scheduled_at > request_time
):
return False
await grant_subscription_period(
db,
subscription=subscription,
period=period,
request_time=request_time,
)
return True
async def grant_due_subscription_periods(
db: AsyncSession,
*,
request_time: datetime | None = None,
batch_size: int = 100,
) -> int:
checked_at = request_time or utc_now()
candidates = await list_due_subscription_period_candidates(
db, request_time=checked_at, batch_size=batch_size
)
count = 0
for period_id, subscription_id, user_id in candidates:
if await grant_due_subscription_period_by_id(
db,
period_id=period_id,
subscription_id=subscription_id,
user_id=user_id,
request_time=checked_at,
):
count += 1
return count
async def list_due_subscription_ids(
db: AsyncSession,
*,
request_time: datetime,
batch_size: int = 500,
) -> list[str]:
result = await db.execute(
select(UserCreditSubscription.id)
.where(
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.expires_at <= request_time,
)
.order_by(UserCreditSubscription.expires_at.asc(), UserCreditSubscription.id.asc())
.limit(max(1, batch_size))
)
return [str(item) for item in result.scalars().all()]
async def expire_subscription_by_id(
db: AsyncSession,
*,
subscription_id: str,
request_time: datetime,
) -> bool:
result = await db.execute(
select(UserCreditSubscription)
.where(UserCreditSubscription.id == subscription_id)
.with_for_update()
.limit(1)
)
subscription = result.scalar_one_or_none()
if (
subscription is None
or subscription.status != CreditSubscriptionStatus.ACTIVE.value
or subscription.expires_at > request_time
):
return False
subscription.status = CreditSubscriptionStatus.EXPIRED.value
subscription.next_grant_at = None
await db.flush()
return True
async def expire_due_subscriptions(
db: AsyncSession,
*,
request_time: datetime | None = None,
batch_size: int = 500,
) -> int:
checked_at = request_time or utc_now()
ids = await list_due_subscription_ids(db, request_time=checked_at, batch_size=batch_size)
count = 0
for subscription_id in ids:
if await expire_subscription_by_id(
db, subscription_id=subscription_id, request_time=checked_at
):
count += 1
return count
async def revoke_payment_order_credits(
db: AsyncSession,
*,
order: PaymentOrder,
reason: str,
request_time: datetime | None = None,
) -> float:
"""支付现有退款流程的积分账本适配:只撤销该订单当前尚未消费的积分。"""
from app.services.credit.ledger_service import revoke_balances
checked_at = request_time or utc_now()
await acquire_user_credit_lock(db, order.user_id)
balance_result = await db.execute(
select(UserCreditBalance)
.where(
UserCreditBalance.payment_order_id == order.id,
UserCreditBalance.user_id == order.user_id,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.valid_from <= checked_at,
UserCreditBalance.expires_at > checked_at,
UserCreditBalance.revoked_at.is_(None),
)
.order_by(UserCreditBalance.id.asc())
.with_for_update()
)
balances = list(balance_result.scalars().all())
revoked = sum((to_credit_decimal(item.unspent_amount) for item in balances), Decimal("0.00"))
if balances:
await revoke_balances(
db,
balances=balances,
description=f"{reason}:撤销订单未消费积分",
related_id=order.id,
biz_key=f"payment-order:{order.id}:refund-revoke",
request_time=checked_at,
)
if order.subscription_id:
subscription_result = await db.execute(
select(UserCreditSubscription)
.where(UserCreditSubscription.id == order.subscription_id)
.limit(1)
.with_for_update()
)
subscription = subscription_result.scalar_one_or_none()
if subscription:
subscription.status = CreditSubscriptionStatus.REFUNDED.value
periods_result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(
UserCreditSubscriptionPeriod.subscription_id == subscription.id,
UserCreditSubscriptionPeriod.status.in_([
CreditSubscriptionPeriodStatus.SCHEDULED.value,
CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value,
]),
)
.order_by(UserCreditSubscriptionPeriod.id.asc())
.with_for_update()
)
for period in periods_result.scalars().all():
period.status = CreditSubscriptionPeriodStatus.CANCELLED.value
period.cancelled_at = checked_at
period.upgrade_order_id = None
period.reserved_at = None
await db.flush()
return float(revoked)
@@ -0,0 +1,39 @@
from __future__ import annotations
import calendar
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
from app.services.credit.utils import ensure_aware
BUSINESS_TZ = ZoneInfo("Asia/Shanghai")
def to_business_time(value: datetime) -> datetime:
return ensure_aware(value).astimezone(BUSINESS_TZ)
def add_natural_months(anchor_at: datetime, months: int) -> datetime:
"""始终基于传入锚点计算自然月,月底压缩后下月恢复原锚点日。"""
anchor = to_business_time(anchor_at)
month_index = anchor.year * 12 + anchor.month - 1 + int(months)
year, month_zero = divmod(month_index, 12)
month = month_zero + 1
target_day = min(anchor.day, calendar.monthrange(year, month)[1])
return anchor.replace(year=year, month=month, day=target_day)
def natural_month_period(anchor_at: datetime, sequence: int) -> tuple[datetime, datetime]:
start = add_natural_months(anchor_at, sequence)
end = add_natural_months(anchor_at, sequence + 1)
return start, end
def next_local_midnight(value: datetime) -> datetime:
local = to_business_time(value)
next_day = local.date() + timedelta(days=1)
return datetime.combine(next_day, time.min, tzinfo=BUSINESS_TZ)
def last_usable_at(expires_at: datetime) -> datetime:
return ensure_aware(expires_at) - timedelta(microseconds=1)
@@ -0,0 +1,163 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_product import SubscriptionBillingCycle
from app.enums.credit_subscription import CreditSubscriptionPeriodStatus
from app.models.credit.product import CreditProduct
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
from app.models.payment_order import PaymentOrder
from app.models.user import User
from app.services.credit.locking import acquire_user_credit_lock
from app.services.credit.product_service import (
ProductPriceQuote,
current_product_price,
get_active_subscription,
)
from app.services.credit.utils import to_credit_decimal
async def quote_and_reserve_product_purchase(
db: AsyncSession,
*,
user: User,
product: CreditProduct,
order_id: str,
request_time: datetime,
) -> ProductPriceQuote:
if product.is_credit_addon:
price = to_credit_decimal(product.price)
return ProductPriceQuote(
product=product,
purchase_scene="credit_addon",
price_type="regular",
base_price=price,
activity_price=None,
target_price=price,
deduction_amount=Decimal("0.00"),
payable_amount=price,
)
# 所有订阅购买/升级先取得统一用户积分事务锁,再锁订阅和周期,
# 与月度发放、支付履约保持同一锁顺序,避免升级边界死锁。
await acquire_user_credit_lock(db, user.id)
pending_result = await db.execute(
select(PaymentOrder.id).where(
PaymentOrder.user_id == user.id,
PaymentOrder.id != order_id,
PaymentOrder.status == "pending",
PaymentOrder.product_type == "subscription",
).limit(1)
)
if pending_result.scalar_one_or_none() is not None:
raise ValueError("已有待支付的订阅或升级订单,请先完成或等待订单过期")
current = await get_active_subscription(
db,
user.id,
request_time=request_time,
for_update=True,
)
first_purchase = user.first_membership_paid_at is None
if not first_purchase and not bool(product.renewal_enabled):
raise ValueError("该订阅套餐暂未开放续费或升级")
if current is None:
base, activity, target, price_type = current_product_price(
product,
first_purchase=first_purchase,
request_time=request_time,
upgrade=False,
)
return ProductPriceQuote(
product=product,
purchase_scene="first_purchase" if first_purchase else "renewal",
price_type=price_type,
base_price=base,
activity_price=activity,
target_price=target,
deduction_amount=Decimal("0.00"),
payable_amount=target,
)
if product.billing_cycle != current.billing_cycle:
raise ValueError("当前订阅有效,只能升级同周期更高等级套餐")
if int(product.tier_rank or 0) <= int(current.tier_rank or 0):
raise ValueError("当前订阅有效,不能提前续费或降级")
base, activity, target, price_type = current_product_price(
product,
first_purchase=False,
request_time=request_time,
upgrade=True,
)
period_ids: list[str] = []
periods: list[UserCreditSubscriptionPeriod] = []
deduction = Decimal("0.00")
if current.billing_cycle in {
SubscriptionBillingCycle.QUARTERLY.value,
SubscriptionBillingCycle.YEARLY.value,
}:
result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(
UserCreditSubscriptionPeriod.subscription_id == current.id,
UserCreditSubscriptionPeriod.scheduled_at > request_time,
UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.SCHEDULED.value,
)
.order_by(UserCreditSubscriptionPeriod.sequence.asc())
.with_for_update()
)
periods = list(result.scalars().all())
for period in periods:
deduction += to_credit_decimal(period.allocated_paid_amount)
period_ids.append(period.id)
payable = target - deduction
if payable <= Decimal("0.00"):
raise ValueError("当前升级抵扣金额已达到或超过目标套餐价格,暂不支持0元升级,请联系客服处理")
for period in periods:
period.status = CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value
period.upgrade_order_id = order_id
period.reserved_at = request_time
return ProductPriceQuote(
product=product,
purchase_scene="upgrade",
price_type=price_type,
base_price=base,
activity_price=activity,
target_price=target,
deduction_amount=deduction,
payable_amount=payable,
source_subscription_id=current.id,
upgrade_period_ids=tuple(period_ids),
)
async def release_upgrade_reservation(
db: AsyncSession,
*,
order: PaymentOrder,
released_at: datetime,
) -> int:
period_ids = list(order.upgrade_period_ids_json or [])
if not period_ids:
return 0
await acquire_user_credit_lock(db, order.user_id)
result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(
UserCreditSubscriptionPeriod.id.in_(period_ids),
UserCreditSubscriptionPeriod.upgrade_order_id == order.id,
UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value,
)
.with_for_update()
)
periods = list(result.scalars().all())
for period in periods:
period.status = CreditSubscriptionPeriodStatus.SCHEDULED.value
period.upgrade_order_id = None
period.reserved_at = None
await db.flush()
return len(periods)
@@ -0,0 +1,28 @@
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal, ROUND_HALF_UP
from typing import Any
CREDIT_QUANT = Decimal("0.01")
def to_credit_decimal(value: Any) -> Decimal:
try:
return Decimal(str(value or 0)).quantize(CREDIT_QUANT, rounding=ROUND_HALF_UP)
except Exception as exc:
raise ValueError(f"无效积分数值: {value!r}") from exc
def to_float(value: Decimal | int | float | None) -> float:
return float(to_credit_decimal(value))
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def ensure_aware(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
+137 -210
View File
@@ -1,5 +1,4 @@
import math
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
@@ -9,23 +8,6 @@ from app.models.credit_record import CreditRecord
from app.models.video_engine import VideoEngine
from app.models.image_engine import ImageEngine
from app.models.credit_ratio import CreditRatio
from app.utils.id_gen import generate_id
from app.utils.exceptions import InsufficientCreditsError
from app.enums.common import BillingBlockEventEnum
from app.services.operation_log_service import log_operation_event
from app.services.system_config_cache import get_system_config_value
from app.services.credit_record_meta_service import CreditRecordMeta, with_user_snapshot
async def calc_text_credits(db: AsyncSession, input_tokens: int, output_tokens: int) -> float:
"""Calculate text credits based on actual token usage and cached configurable rate."""
raw_rate = await get_system_config_value(db, "text_credits_per_1000_tokens")
try:
rate = float(raw_rate) if raw_rate not in (None, "") else 1.0
except (TypeError, ValueError):
rate = 1.0
total_tokens = input_tokens + output_tokens
return round(total_tokens * rate / 1000, 2)
async def _get_credit_ratio(
@@ -176,31 +158,19 @@ async def calc_image_credits(
return round(total, 2)
@dataclass(slots=True)
class CreditMutationResult:
user: User
record: CreditRecord | None
created: bool
amount: float
balance_before: float
balance_after: float
async def _get_existing_credit_record_by_biz_key(
db: AsyncSession,
*,
user_id: str,
biz_key: str | None,
) -> CreditRecord | None:
"""按正式业务幂等键查找已有积分流水。"""
if not biz_key:
return None
result = await db.execute(
select(CreditRecord)
.where(CreditRecord.user_id == user_id, CreditRecord.biz_key == biz_key)
.limit(1)
)
return result.scalar_one_or_none()
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
from app.enums.credit_record import CreditRecordType
from app.services.credit.ledger_service import (
CreditMutationResult,
deduct_credits as deduct_dynamic_credits,
grant_credits,
refund_consumption,
)
from app.services.credit.query_service import get_available_credits
from app.services.credit.time_policy import add_natural_months
from app.services.credit.utils import to_float, utc_now
from app.services.credit_record_meta_service import CreditRecordMeta
async def deduct_credits_result(
@@ -216,94 +186,21 @@ async def deduct_credits_result(
record_type: str = "consume",
allow_negative: bool = False,
create_zero_record: bool = False,
request_time: datetime | None = None,
) -> CreditMutationResult:
"""并发安全且可观察幂等结果的积分扣减"""
amount = round(float(amount or 0), 2)
result = await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1))
user = result.scalar_one_or_none()
if not user:
raise ValueError("User not found")
before_balance = round(float(user.credits or 0), 2)
if biz_key:
existing = await _get_existing_credit_record_by_biz_key(db, user_id=user_id, biz_key=biz_key)
if existing:
return CreditMutationResult(
user=user,
record=existing,
created=False,
amount=abs(round(float(existing.amount or 0), 2)),
balance_before=before_balance,
balance_after=before_balance,
)
if amount <= 0 and not create_zero_record:
return CreditMutationResult(
user=user,
record=None,
created=False,
amount=0.0,
balance_before=before_balance,
balance_after=before_balance,
)
if amount > 0 and not allow_negative and before_balance < amount:
event_type = (
BillingBlockEventEnum.NEGATIVE_BALANCE.value
if before_balance < 0
else BillingBlockEventEnum.INSUFFICIENT_CREDITS.value
)
log_operation_event(
domain="billing",
module="credits",
event_type=event_type,
event_status="failed",
source="app.services.credits.deduct_credits_result",
user_id=user_id,
task_id=related_id,
message="积分不足,已拦截新的扣费请求",
detail={
"user_id": user_id,
"amount": amount,
"before_balance": before_balance,
"allow_negative": allow_negative,
"biz_key": biz_key,
"refund_for_biz_key": refund_for_biz_key,
"description": description,
"record_type": record_type,
},
)
raise InsufficientCreditsError()
user.credits = round(before_balance - max(0.0, amount), 2)
meta_kwargs: dict = {}
if record_meta:
if isinstance(record_meta, CreditRecordMeta):
record_meta = await with_user_snapshot(db, record_meta, user_id, user=user)
meta_kwargs = record_meta.to_record_kwargs()
elif isinstance(record_meta, dict):
meta_kwargs = {k: v for k, v in record_meta.items() if v is not None}
record = CreditRecord(
id=generate_id(),
"""旧调用兼容门面;新账本始终足额同步扣除,allow_negative 不再生效"""
return await deduct_dynamic_credits(
db,
user_id=user_id,
type=record_type,
amount=-amount,
balance_after=user.credits,
amount=amount,
description=description,
related_id=related_id,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
**meta_kwargs,
)
db.add(record)
await db.flush()
return CreditMutationResult(
user=user,
record=record,
created=True,
amount=amount,
balance_before=before_balance,
balance_after=round(float(user.credits or 0), 2),
record_meta=record_meta,
record_type=record_type,
create_zero_record=create_zero_record,
request_time=request_time,
)
@@ -320,22 +217,24 @@ async def deduct_credits(
record_type: str = "consume",
allow_negative: bool = False,
create_zero_record: bool = False,
request_time: datetime | None = None,
) -> User:
"""兼容旧调用:返回 User;精确幂等状态请使用 deduct_credits_result。"""
mutation = await deduct_credits_result(
db,
user_id=user_id,
amount=amount,
description=description,
related_id=related_id,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
record_meta=record_meta,
record_type=record_type,
allow_negative=allow_negative,
create_zero_record=create_zero_record,
)
return mutation.user
return (
await deduct_credits_result(
db,
user_id=user_id,
amount=amount,
description=description,
related_id=related_id,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
record_meta=record_meta,
record_type=record_type,
allow_negative=allow_negative,
create_zero_record=create_zero_record,
request_time=request_time,
)
).user
async def add_credits_result(
@@ -349,66 +248,63 @@ async def add_credits_result(
biz_key: str | None = None,
refund_for_biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
valid_from: datetime | None = None,
expires_at: datetime | None = None,
credit_level: str = CreditLevel.GENERAL.value,
source_type: str = CreditBalanceSourceType.ADMIN_GRANT.value,
source_id: str | None = None,
product_id: str | None = None,
payment_order_id: str | None = None,
subscription_id: str | None = None,
subscription_period_id: str | None = None,
request_time: datetime | None = None,
) -> CreditMutationResult:
"""并发安全且可观察幂等结果的积分增加。"""
amount = round(float(amount or 0), 2)
result = await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1))
user = result.scalar_one_or_none()
if not user:
raise ValueError("User not found")
before_balance = round(float(user.credits or 0), 2)
if biz_key:
existing = await _get_existing_credit_record_by_biz_key(db, user_id=user_id, biz_key=biz_key)
if existing:
return CreditMutationResult(
user=user,
record=existing,
created=False,
amount=abs(round(float(existing.amount or 0), 2)),
balance_before=before_balance,
balance_after=before_balance,
)
if amount <= 0:
checked_at = request_time or utc_now()
if record_type == CreditRecordType.REFUND.value and refund_for_biz_key:
refunded = await refund_consumption(
db,
user_id=user_id,
refund_for_biz_key=refund_for_biz_key,
description=description,
related_id=related_id,
biz_key=biz_key,
record_meta=record_meta,
refund_time=checked_at,
)
user_result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = user_result.scalar_one()
setattr(user, "credits", to_float(refunded.balance_after))
record = refunded.records[0] if refunded.records else None
return CreditMutationResult(
user=user,
record=None,
created=False,
amount=0.0,
balance_before=before_balance,
balance_after=before_balance,
record=record,
created=refunded.created,
amount=to_float(refunded.total_amount),
balance_before=to_float(refunded.balance_before),
balance_after=to_float(refunded.balance_after),
refund_available=to_float(refunded.available_amount),
refund_expired=to_float(refunded.expired_amount),
)
user.credits = round(before_balance + amount, 2)
meta_kwargs: dict = {}
if record_meta:
if isinstance(record_meta, CreditRecordMeta):
record_meta = await with_user_snapshot(db, record_meta, user_id, user=user)
meta_kwargs = record_meta.to_record_kwargs()
elif isinstance(record_meta, dict):
meta_kwargs = {k: v for k, v in record_meta.items() if v is not None}
record = CreditRecord(
id=generate_id(),
starts_at = valid_from or checked_at
return await grant_credits(
db,
user_id=user_id,
type=record_type,
amount=amount,
balance_after=user.credits,
description=description,
source_type=source_type,
valid_from=starts_at,
expires_at=expires_at or add_natural_months(starts_at, 1),
credit_level=credit_level,
source_id=source_id or related_id,
product_id=product_id,
payment_order_id=payment_order_id,
subscription_id=subscription_id,
subscription_period_id=subscription_period_id,
related_id=related_id,
record_type=record_type,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
**meta_kwargs,
)
db.add(record)
await db.flush()
return CreditMutationResult(
user=user,
record=record,
created=True,
amount=amount,
balance_before=before_balance,
balance_after=round(float(user.credits or 0), 2),
record_meta=record_meta,
request_time=checked_at,
)
@@ -423,20 +319,40 @@ async def add_credits(
biz_key: str | None = None,
refund_for_biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
valid_from: datetime | None = None,
expires_at: datetime | None = None,
credit_level: str = CreditLevel.GENERAL.value,
source_type: str = CreditBalanceSourceType.ADMIN_GRANT.value,
source_id: str | None = None,
product_id: str | None = None,
payment_order_id: str | None = None,
subscription_id: str | None = None,
subscription_period_id: str | None = None,
request_time: datetime | None = None,
) -> User:
"""兼容旧调用:返回 User;精确幂等状态请使用 add_credits_result。"""
mutation = await add_credits_result(
db,
user_id=user_id,
amount=amount,
description=description,
related_id=related_id,
record_type=record_type,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
record_meta=record_meta,
)
return mutation.user
return (
await add_credits_result(
db,
user_id=user_id,
amount=amount,
description=description,
related_id=related_id,
record_type=record_type,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
record_meta=record_meta,
valid_from=valid_from,
expires_at=expires_at,
credit_level=credit_level,
source_type=source_type,
source_id=source_id,
product_id=product_id,
payment_order_id=payment_order_id,
subscription_id=subscription_id,
subscription_period_id=subscription_period_id,
request_time=request_time,
)
).user
async def refund_credits(
@@ -449,30 +365,41 @@ async def refund_credits(
biz_key: str | None = None,
refund_for_biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
request_time: datetime | None = None,
) -> User:
"""生成失败积分回退。"""
if not refund_for_biz_key:
raise ValueError("动态积分退款必须指定原消费 biz_key")
return await add_credits(
db,
user_id=user_id,
amount=amount,
description=description,
related_id=related_id,
record_type="refund",
record_type=CreditRecordType.REFUND.value,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
record_meta=record_meta,
request_time=request_time,
)
async def get_records(db: AsyncSession, user_id: str, page: int = 1, page_size: int = 20) -> tuple[list[CreditRecord], int]:
async def get_credit_balance(db: AsyncSession, user_id: str, request_time: datetime | None = None) -> float:
return to_float(await get_available_credits(db, user_id, request_time=request_time or utc_now()))
async def get_records(
db: AsyncSession,
user_id: str,
page: int = 1,
page_size: int = 20,
) -> tuple[list[CreditRecord], int]:
count_query = select(func.count(CreditRecord.id)).where(CreditRecord.user_id == user_id)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(
select(CreditRecord)
.where(CreditRecord.user_id == user_id)
.order_by(CreditRecord.created_at.desc())
.order_by(CreditRecord.created_at.desc(), CreditRecord.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return list(result.scalars().all()), total
return list(result.scalars().all()), int(total)
@@ -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|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", "hold", "hold_release"):
raise ValueError("action 仅支持 charge/refund/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,
*,
@@ -28,7 +28,6 @@ from app.enums.generation_status import (
GenerationStatus,
GenerationType,
)
from app.enums.llm_billing import LlmBillingConfigKey
from app.models.generation_record import GenerationRecord
from app.models.project import Project
from app.schemas.generation import OptimizeParams
@@ -51,12 +50,12 @@ from app.services.generation.pipeline.generation_record_config_service import (
from app.services.llm import optimize_prompt
from app.services.llm_billing import (
LlmBillingContext,
log_provider_failure,
record_provider_exception,
log_provider_start,
log_provider_success,
release_on_failure,
settle_success,
start_hold,
finalize_llm_business_failure,
mark_business_success,
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
@@ -110,7 +109,6 @@ def _billing_context(*, user_id: str, record_id: str, request_id: str | None) ->
billing_scene=CreditRecordBillingScene.GENERATION_RECORD_TEXT_PROMPT_OPTIMIZE.value,
source_module=CreditRecordSourceModule.GENERATION_RECORD.value,
related_id=record_id,
hold_config_key=LlmBillingConfigKey.HOLD_GENERATION_RECORD_PROMPT.value,
description_prefix="AI创作提示词优化",
trace_id=f"generation-optimize:{record_id}",
request_id=request_id,
@@ -246,14 +244,14 @@ async def _settle_staged_result(
detail={"status": status_snapshot, "attempt_no": _PROMPT_ATTEMPT_NO},
)
try:
billing = await settle_success(
billing = await mark_business_success(
db,
ctx,
usage=usage,
description=f"提示词优化 - {project_name}",
)
charge_item = next(
(item for item in billing.items if item.biz_key == ctx.charge_biz_key),
(item for item in billing.items if item.biz_key == ctx.billing_biz_key),
None,
)
record.text_credits_cost = round(float(charge_item.amount if charge_item else 0.0), 2)
@@ -468,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 start_hold(db, ctx)
await charge_llm_credits(db, ctx)
await db.commit()
except Exception:
await db.rollback()
@@ -488,7 +486,7 @@ async def optimize_generation_prompt(
},
)
log_provider_start(ctx, detail={"gen_type": req.gen_type.value})
await log_provider_start(db, ctx, detail={"gen_type": req.gen_type.value})
try:
optimized_prompt, usage = await optimize_prompt(
db,
@@ -507,11 +505,15 @@ async def optimize_generation_prompt(
log_owner_type=OWNER_GENERATION_RECORD,
log_owner_id=record_id,
generation_attempt_no=_PROMPT_ATTEMPT_NO,
fixed_model_config_id=ctx.model_config_id,
fixed_model_snapshot=ctx.model_parameters_snapshot,
)
log_provider_success(ctx, usage=usage)
await log_provider_success(db, ctx, usage=usage)
except Exception as exc:
await db.rollback()
log_provider_failure(ctx, error=str(exc))
provider_succeeded, recovered_usage = await record_provider_exception(db, ctx, exc)
if recovered_usage:
usage = recovered_usage
compensated = False
try:
failed_result = await db.execute(
@@ -521,12 +523,21 @@ async def optimize_generation_prompt(
.limit(1)
)
failed_record = failed_result.scalar_one_or_none()
business_already_succeeded = bool(
failed_record is not None
and failed_record.status in {
GenerationStatus.prompt_optimized.value,
GenerationStatus.generating.value,
GenerationStatus.completed.value,
}
)
if failed_record is not None and failed_record.status == GenerationStatus.optimizing.value:
failed_record.status = GenerationStatus.failed.value
failed_record.error_message = extract_error_message(exc, "提示词")
await release_on_failure(db, ctx, error=str(exc))
compensated = True
await db.commit()
if not business_already_succeeded:
await finalize_llm_business_failure(ctx, error=str(exc))
compensated = True
except Exception:
await db.rollback()
logger.exception("prompt optimize failure compensation failed: record_id=%s", record_id)
@@ -613,6 +624,7 @@ async def optimize_generation_prompt(
await db.rollback()
logger.exception("prompt optimize provider result staging failed: record_id=%s", record_id)
if not staged:
failure = last_stage_error or RuntimeError("unknown staging failure")
log_operation_error(
domain=_LOG_DOMAIN,
event_type=GenerationRecordEventTypeEnum.PROMPT_OPTIMIZE_PROVIDER_RESULT_STAGED.value,
@@ -622,9 +634,45 @@ async def optimize_generation_prompt(
project_id=project_id,
task_id=record_id,
detail={"attempt_no": _PROMPT_ATTEMPT_NO, "stage": "provider_result_persistence"},
exc=last_stage_error or RuntimeError("unknown staging failure"),
exc=failure,
)
raise HTTPException(status_code=503, detail="提词已生成但本地暂存失败,请联系管理员根据模型日志处理")
# 供应商调用和 Token 已由独立调用审计事务保存。结果连续暂存失败后,
# 当前 API 已没有可继续恢复的本地业务结果,必须先结束业务状态,再按
# 原积分来源退回场景积分消费,不能让账务永久停留在 processing。
business_already_succeeded = False
try:
await db.rollback()
failed_result = await db.execute(
select(GenerationRecord)
.where(
GenerationRecord.id == record_id,
GenerationRecord.user_id == user_id,
GenerationRecord.deleted_at.is_(None),
)
.with_for_update()
.limit(1)
)
failed_record = failed_result.scalar_one_or_none()
business_already_succeeded = bool(
failed_record is not None
and failed_record.status in {
GenerationStatus.prompt_optimized.value,
GenerationStatus.generating.value,
GenerationStatus.completed.value,
}
)
if failed_record is not None and not business_already_succeeded:
failed_record.status = GenerationStatus.failed.value
failed_record.pipeline_stage = None
failed_record.error_message = "提词已生成但本地结果暂存失败"
await db.commit()
except Exception:
await db.rollback()
logger.exception("prompt optimize staging final failure persistence failed: record_id=%s", record_id)
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="提词已生成但本地暂存失败,场景消费积分已按原来源退回")
_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[: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,
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,
}
@@ -12,7 +12,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType
from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.hot_opening_replicate import HotOpeningGenerationModeEnum, HotOpeningStepCodeEnum, ModuleCodeEnum
from app.models.chat_generation_task import ChatGenerationTask
from app.models.module_generation_project import ModuleGenerationProject
@@ -55,13 +54,15 @@ 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_hold_exists,
ensure_llm_charged,
log_provider_failure,
record_provider_exception,
log_provider_start,
log_provider_success,
release_on_failure,
settle_success,
start_hold,
refund_on_final_failure,
finalize_llm_business_failure,
mark_business_success,
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,
@@ -892,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 start_hold(
await charge_llm_credits(
db,
LlmBillingContext(
user_id=str(project.user_id),
@@ -906,9 +907,8 @@ async def submit_image_prompt_optimize(
source_step_id=str(step.id),
source_step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
related_id=str(step.id),
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value,
description_prefix="爆款开头复刻图片AI提词优化",
trace_id=f"llm-submit-hold:{step.id}",
trace_id=f"llm-submit-pre-deduct:{step.id}",
),
)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_SUBMITTED.value, message="图片 AI 提词任务已提交")
@@ -1005,14 +1005,13 @@ async def run_image_prompt_optimize(
source_step_id=step_id_value,
source_step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
related_id=step_id_value,
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value,
description_prefix="爆款开头复刻图片AI提词优化",
description_prefix="爆款开头复刻图片AI提词优化",
trace_id=f"hot-opening-image-prompt:{step_id_value}",
)
hold_validation = await ensure_hold_exists(db, llm_billing_context)
if not hold_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账务状态异常({hold_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
@@ -1023,7 +1022,7 @@ async def run_image_prompt_optimize(
provider_succeeded = False
token_usage: dict[str, Any] = {}
log_provider_start(llm_billing_context, detail={"prompt_type": "image"})
await log_provider_start(db, llm_billing_context, detail={"prompt_type": "image"})
try:
request_log = {"original_prompt": prompt_text, "references": references, "gen_type": "image"}
log_module_prompt_event(
@@ -1048,9 +1047,11 @@ async def run_image_prompt_optimize(
log_owner_type="module_generation_step",
log_owner_id=step_id_value,
generation_attempt_no=expected_step_version,
fixed_model_config_id=llm_billing_context.model_config_id,
fixed_model_snapshot=llm_billing_context.model_parameters_snapshot,
)
provider_succeeded = True
log_provider_success(llm_billing_context, usage=token_usage)
await log_provider_success(db, llm_billing_context, usage=token_usage)
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1064,16 +1065,10 @@ async def run_image_prompt_optimize(
expected_version=expected_step_version,
expected_input_json=expected_input_json,
):
# Provider 已成功,旧步骤即使失效也必须按真实 usage 结算,不能免费释放。
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="爆款开头复刻-图片AI提词优化(失效结果结算)",
)
await db.commit()
await log_provider_failure(db, llm_billing_context, error="当前步骤已失效,业务结果未采用")
await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务结果未采用")
return None
billing = await settle_success(
billing = await mark_business_success(
db,
llm_billing_context,
usage=token_usage,
@@ -1121,21 +1116,19 @@ async def run_image_prompt_optimize(
except DatabaseRowLockBusy:
await db.rollback()
if provider_succeeded:
# Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="爆款开头复刻-图片AI提词优化(行锁失败结算)",
)
await db.commit()
await log_provider_failure(db, llm_billing_context, error="本地行锁冲突,业务结果未落库")
await finalize_llm_business_failure(llm_billing_context, error="本地行锁冲突,业务结果未落库")
return None
# Provider 尚未成功才允许同一 attempt 做系统自动重试。
raise
except Exception as exc:
await db.rollback()
if not provider_succeeded:
log_provider_failure(llm_billing_context, error=str(exc))
if provider_succeeded:
await log_provider_failure(db, llm_billing_context, error=str(exc))
else:
provider_succeeded, recovered_usage = await record_provider_exception(db, llm_billing_context, exc)
if recovered_usage:
token_usage = recovered_usage
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1150,16 +1143,7 @@ async def run_image_prompt_optimize(
expected_input_json=expected_input_json,
):
await db.rollback()
if provider_succeeded:
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="爆款开头复刻-图片AI提词优化(异常失效结算)",
)
else:
await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分")
await db.commit()
await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务最终失败")
return None
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = str(exc) if str(exc) else type(exc).__name__
@@ -1178,16 +1162,8 @@ async def run_image_prompt_optimize(
)
_log_project_error(project=project, step=step, event_type="IMAGE_PROMPT_FAILED", message=project.error_message, exc=exc)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_FAILED.value, message=project.error_message)
if provider_succeeded:
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="爆款开头复刻-图片AI提词优化(本地失败结算)",
)
else:
await release_on_failure(db, llm_billing_context, error=str(exc))
await db.commit()
await finalize_llm_business_failure(llm_billing_context, error=str(exc))
return step
@@ -1351,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 start_hold(
await charge_llm_credits(
db,
LlmBillingContext(
user_id=str(project.user_id),
@@ -1365,9 +1341,8 @@ async def submit_video_prompt_optimize(
source_step_id=str(step.id),
source_step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
related_id=str(step.id),
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
description_prefix="爆款开头复刻视频AI提词优化",
trace_id=f"llm-submit-hold:{step.id}",
trace_id=f"llm-submit-pre-deduct:{step.id}",
),
)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_SUBMITTED.value, message="视频 AI 提词任务已提交")
@@ -1462,14 +1437,13 @@ async def run_video_prompt_optimize(
source_step_id=step_id_value,
source_step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
related_id=step_id_value,
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
description_prefix="爆款开头复刻视频AI提词优化",
description_prefix="爆款开头复刻视频AI提词优化",
trace_id=f"hot-opening-video-prompt:{step_id_value}",
)
hold_validation = await ensure_hold_exists(db, llm_billing_context)
if not hold_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账务状态异常({hold_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
@@ -1480,7 +1454,7 @@ async def run_video_prompt_optimize(
provider_succeeded = False
token_usage: dict[str, Any] = {}
log_provider_start(llm_billing_context, detail={"prompt_type": "video"})
await log_provider_start(db, llm_billing_context, detail={"prompt_type": "video"})
try:
request_log = {
"source_project_name": material.get("source_project_name") or "",
@@ -1517,9 +1491,11 @@ async def run_video_prompt_optimize(
project_id=project_id_value,
step_id=step_id_value,
trace_id=f"hot-video-prompt:{step_id_value}",
fixed_model_config_id=llm_billing_context.model_config_id,
fixed_model_snapshot=llm_billing_context.model_parameters_snapshot,
)
provider_succeeded = True
log_provider_success(llm_billing_context, usage=token_usage)
await log_provider_success(db, llm_billing_context, usage=token_usage)
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1533,15 +1509,10 @@ async def run_video_prompt_optimize(
expected_version=expected_step_version,
expected_input_json=expected_input_json,
):
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="爆款开头复刻-视频AI提词优化(失效结果结算)",
)
await db.commit()
await log_provider_failure(db, llm_billing_context, error="当前步骤已失效,业务结果未采用")
await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务结果未采用")
return None
billing = await settle_success(
billing = await mark_business_success(
db,
llm_billing_context,
usage=token_usage,
@@ -1592,21 +1563,19 @@ async def run_video_prompt_optimize(
except DatabaseRowLockBusy:
await db.rollback()
if provider_succeeded:
# Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="爆款开头复刻-视频AI提词优化(行锁失败结算)",
)
await db.commit()
await log_provider_failure(db, llm_billing_context, error="本地行锁冲突,业务结果未落库")
await finalize_llm_business_failure(llm_billing_context, error="本地行锁冲突,业务结果未落库")
return None
# Provider 尚未成功才允许同一 attempt 做系统自动重试。
raise
except Exception as exc:
await db.rollback()
if not provider_succeeded:
log_provider_failure(llm_billing_context, error=str(exc))
if provider_succeeded:
await log_provider_failure(db, llm_billing_context, error=str(exc))
else:
provider_succeeded, recovered_usage = await record_provider_exception(db, llm_billing_context, exc)
if recovered_usage:
token_usage = recovered_usage
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1621,16 +1590,7 @@ async def run_video_prompt_optimize(
expected_input_json=expected_input_json,
):
await db.rollback()
if provider_succeeded:
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="爆款开头复刻-视频AI提词优化(异常失效结算)",
)
else:
await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分")
await db.commit()
await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务最终失败")
return None
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = str(exc) if str(exc) else type(exc).__name__
@@ -1649,16 +1609,8 @@ async def run_video_prompt_optimize(
)
_log_project_error(project=project, step=step, event_type="VIDEO_PROMPT_FAILED", message=project.error_message, exc=exc)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value, message=project.error_message)
if provider_succeeded:
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="爆款开头复刻-视频AI提词优化(本地失败结算)",
)
else:
await release_on_failure(db, llm_billing_context, error=str(exc))
await db.commit()
await finalize_llm_business_failure(llm_billing_context, error=str(exc))
return step
@@ -1946,7 +1898,7 @@ async def mark_hot_opening_step_dispatch_failed(
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = error_message
if step.step_code in (HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value):
await release_on_failure(
await refund_on_final_failure(
db,
LlmBillingContext(
user_id=str(project.user_id),
@@ -1964,11 +1916,6 @@ async def mark_hot_opening_step_dispatch_failed(
source_step_id=str(step.id),
source_step_code=str(step.step_code),
related_id=str(step.id),
hold_config_key=(
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
if step.step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
),
description_prefix=(
"爆款开头复刻图片AI提词优化"
if step.step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
@@ -15,6 +15,7 @@ from app.enums.common import LogEventStatusEnum, LogSourceEnum
from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningRemoteActionEnum, ModuleCodeEnum as HotModuleCodeEnum
from app.enums.shot_replicate import ModuleCodeEnum as ShotModuleCodeEnum, ShotReplicateLogEventEnum, ShotReplicateRemoteActionEnum
from app.services.operation_log_service import log_ai_model_event
from app.services.llm_billing.context import LlmProviderPostprocessError
from app.enums.common import (
VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE,
VIDEO_SCHEMA_CONFIG_VERSION,
@@ -1545,8 +1546,15 @@ def _log_video_prompt_ai_event(
**common,
)
async def _select_model_config(db: AsyncSession) -> ModelConfig | None:
result = await db.execute(select(ModelConfig).where(ModelConfig.is_active == True, ModelConfig.deleted_at.is_(None)).order_by(ModelConfig.priority.desc()).limit(1))
async def _select_model_config(db: AsyncSession, model_config_id: str | None = None) -> ModelConfig | None:
stmt = select(ModelConfig).where(
ModelConfig.is_active == True,
ModelConfig.deleted_at.is_(None),
ModelConfig.provider != "mock",
)
if model_config_id:
stmt = stmt.where(ModelConfig.id == model_config_id)
result = await db.execute(stmt.order_by(ModelConfig.priority.desc(), ModelConfig.id.asc()).limit(1))
return result.scalar_one_or_none()
@@ -1566,6 +1574,8 @@ async def optimize_hot_opening_video_prompt(
project_id: str | None = None,
step_id: str | None = None,
trace_id: str | None = None,
fixed_model_config_id: str | None = None,
fixed_model_snapshot: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], str, dict[str, Any]]:
call_id = generate_id()
duration = int(video_config["duration"])
@@ -1580,15 +1590,18 @@ async def optimize_hot_opening_video_prompt(
# result = ensure_negative_prompt(ensure_flow_matches_time_plan(ensure_top_keys(fill_none_with_wu(result)), duration))
# return result, build_final_video_prompt(result), {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
config_row = await _select_model_config(db)
config_row = await _select_model_config(db, fixed_model_config_id)
snapshot = dict(fixed_model_snapshot or {})
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 ""),
name=str(snapshot.get("name") or config_row.name or ""),
provider=str(snapshot.get("provider") or config_row.provider or ""),
api_base=str(snapshot.get("api_base") or config_row.api_base or ""),
api_key=str(config_row.api_key or ""),
model_name=str(config_row.model_name or ""),
model_name=str(snapshot.get("model_name") or config_row.model_name or ""),
max_tokens=snapshot.get("max_tokens"),
temperature=snapshot.get("temperature"),
)
if config_row is not None
else None
@@ -1597,14 +1610,7 @@ async def optimize_hot_opening_video_prompt(
# 只读事务,后续文件读取/Base64 转换及远程请求不能占用数据库连接。
await db.rollback()
if not config:
result = normalize_video_prompt_schema_from_ai(_mock_result(video_config, target_platform), video_config, schema_config_snapshot)
return result, build_final_video_prompt(result), {
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0,
"usage_reported": True,
"billing_free": True,
}
raise RuntimeError("固定LLM模型不存在、已停用或为Mock模型,禁止自动切换和降级")
if use_base64:
video_url_final = await media_to_base64(material_video_url, "video/mp4")
@@ -1720,13 +1726,10 @@ async def optimize_hot_opening_video_prompt(
try:
data = response.json()
content = data["choices"][0]["message"]["content"].strip()
if not content:
raise RuntimeError("视频提词模型响应 content 为空")
except Exception as exc:
parse_event, remote_action = _video_prompt_remote_event(module, empty="content 为空" in str(exc), parse_failed="content 为空" not in str(exc))
parse_event, remote_action = _video_prompt_remote_event(module, parse_failed=True)
_log_video_prompt_ai_event(
call_id=call_id,
call_id=call_id,
module=module,
event_type=parse_event,
action=remote_action,
@@ -1740,7 +1743,7 @@ async def optimize_hot_opening_video_prompt(
response_data=response_data,
http_status=response.status_code,
remote_request_id=remote_request_id,
message="视频提词模型响应解析失败",
message="视频提词模型响应 JSON 解析失败",
error=str(exc),
)
raise
@@ -1765,6 +1768,9 @@ async def optimize_hot_opening_video_prompt(
})
try:
content = data["choices"][0]["message"]["content"].strip()
if not content:
raise RuntimeError("视频提词模型响应 content 为空")
result = parse_model_json(content)
result = normalize_video_prompt_schema_from_ai(result, video_config, schema_config_snapshot)
except Exception as exc:
@@ -1787,7 +1793,7 @@ async def optimize_hot_opening_video_prompt(
message="视频提词业务 JSON 解析失败",
error=str(exc),
)
raise
raise LlmProviderPostprocessError(f"视频提词响应后处理失败: {exc}", usage=token_usage) from exc
success_event, remote_action = _video_prompt_remote_event(module, success=True)
_log_video_prompt_ai_event(
call_id=call_id,
+64 -81
View File
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.model_config import ModelConfig
from app.services.operation_log_service import build_exception_detail, log_ai_model_event
from app.services.llm_billing.context import LlmProviderPostprocessError
from app.utils.id_gen import generate_id
@@ -74,66 +75,52 @@ async def optimize_prompt(
log_owner_type: str | None = None,
log_owner_id: str | None = None,
generation_attempt_no: int | None = None,
fixed_model_config_id: str | None = None,
fixed_model_snapshot: dict | None = None,
) -> tuple[str, dict]:
"""Optimize user prompt using LLM. Returns (optimized_text, token_usage_dict)."""
result = await db.execute(
select(ModelConfig)
.where(ModelConfig.is_active == True, ModelConfig.deleted_at.is_(None))
.order_by(ModelConfig.priority.desc())
stmt = select(ModelConfig).where(
ModelConfig.is_active.is_(True),
ModelConfig.deleted_at.is_(None),
ModelConfig.provider != "mock",
)
if fixed_model_config_id:
stmt = stmt.where(ModelConfig.id == fixed_model_config_id)
stmt = stmt.order_by(ModelConfig.priority.desc(), ModelConfig.id.asc()).limit(1)
result = await db.execute(stmt)
item = result.scalar_one_or_none()
if item is None:
raise LLMProviderCallError("没有可用的固定LLM模型,本版本禁止Mock、自动切换和降级")
snapshot = dict(fixed_model_snapshot or {})
selected = SimpleNamespace(
id=item.id,
name=snapshot.get("name") or item.name,
provider=snapshot.get("provider") or item.provider,
api_base=snapshot.get("api_base") or item.api_base,
api_key=item.api_key,
model_name=snapshot.get("model_name") or item.model_name,
max_tokens=snapshot.get("max_tokens") if snapshot.get("max_tokens") is not None else item.max_tokens,
temperature=snapshot.get("temperature") if snapshot.get("temperature") is not None else item.temperature,
)
configs = [
SimpleNamespace(
id=item.id,
name=item.name,
provider=item.provider,
api_base=item.api_base,
api_key=item.api_key,
model_name=item.model_name,
max_tokens=item.max_tokens,
temperature=item.temperature,
)
for item in result.scalars().all()
]
# Release the read transaction before the external LLM request. Only
# plain scalar snapshots are used afterwards, so expire_on_commit does
# not trigger an ORM refresh while the provider request is in flight.
await db.commit()
if configs:
# 按 priority 从大到小依次尝试,跳过 mock,失败则用下一个
for selected in configs:
if selected.provider == "mock":
continue
if selected.provider in ("openai_compatible", "sdk"):
try:
return await _call_openai_compatible(
selected, original_prompt, db, user_id, industry_key, duration,
references=references,
gen_type=gen_type,
image_size=image_size,
image_proportion=image_proportion,
image_px=image_px,
log_module=log_module,
log_step=log_step,
log_project_id=log_project_id,
log_task_id=log_task_id,
log_owner_type=log_owner_type,
log_owner_id=log_owner_id,
generation_attempt_no=generation_attempt_no,
)
except LLMProviderCallError:
continue
# 所有真实模型都失败,降级到 mock
mock_cfg = next((c for c in configs if c.provider == "mock"), None)
if mock_cfg:
return _mock_optimize(original_prompt, gen_type)
if settings.LLM_MOCK:
return _mock_optimize(original_prompt, gen_type)
return _get_default_prompt(original_prompt, gen_type)
if selected.provider not in ("openai_compatible", "sdk"):
raise LLMProviderCallError(f"固定模型供应商不受支持:{selected.provider}")
return await _call_openai_compatible(
selected, original_prompt, db, user_id, industry_key, duration,
references=references,
gen_type=gen_type,
image_size=image_size,
image_proportion=image_proportion,
image_px=image_px,
log_module=log_module,
log_step=log_step,
log_project_id=log_project_id,
log_task_id=log_task_id,
log_owner_type=log_owner_type,
log_owner_id=log_owner_id,
generation_attempt_no=generation_attempt_no,
)
def _mock_optimize(prompt: str, gen_type: str = "video") -> tuple[str, dict]:
@@ -435,19 +422,28 @@ async def _call_openai_compatible(
)
raise LLMProviderCallError(f"{type(exc).__name__}: {exc}") from exc
raw_usage = data.get("usage") if isinstance(data, dict) else None
usage_reported = bool(
isinstance(raw_usage, dict)
and any(key in raw_usage for key in ("prompt_tokens", "completion_tokens", "total_tokens"))
)
usage = raw_usage if isinstance(raw_usage, dict) else {}
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)
token_usage = {
"model_config_id": config.id,
"model_config_name": config.name,
"model_provider": config.provider,
"model_name": config.model_name,
"source_module": log_module,
"source_step_code": log_step,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
"usage_reported": usage_reported,
}
try:
raw_usage = data.get("usage")
usage_reported = bool(
isinstance(raw_usage, dict)
and any(
key in raw_usage
for key in ("prompt_tokens", "completion_tokens", "total_tokens")
)
)
usage = raw_usage if isinstance(raw_usage, dict) else {}
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)
content = data["choices"][0]["message"]["content"].strip()
if not content:
raise ValueError("模型未返回有效提示词")
@@ -461,18 +457,5 @@ async def _call_openai_compatible(
error=str(exc),
**common_log,
)
raise LLMProviderCallError(f"模型响应解析失败: {exc}") from exc
token_usage = {
"model_config_id": config.id,
"model_config_name": config.name,
"model_provider": config.provider,
"model_name": config.model_name,
"source_module": log_module,
"source_step_code": log_step,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
"usage_reported": usage_reported,
}
raise LlmProviderPostprocessError(f"模型响应解析失败: {exc}", usage=token_usage) from exc
return content, token_usage
@@ -3,11 +3,13 @@ from app.services.llm_billing.context import (
LlmBillingContext,
LlmBillingPolicy,
LlmBillingStateError,
LlmHoldResult,
LlmHoldValidation,
LlmChargeResult,
LlmChargeValidation,
)
from app.services.llm_billing.service import (
ensure_hold_exists,
charge_llm_credits,
ensure_llm_charged,
finalize_llm_business_failure,
get_llm_ledger_states,
log_celery_dispatch_compensated,
log_celery_dispatch_failure,
@@ -16,9 +18,9 @@ from app.services.llm_billing.service import (
log_provider_failure,
log_provider_start,
log_provider_success,
release_on_failure,
settle_success,
start_hold,
mark_business_success,
record_provider_exception,
refund_on_final_failure,
validate_retryable_previous_attempt,
)
@@ -27,19 +29,21 @@ __all__ = [
"LlmBillingContext",
"LlmBillingPolicy",
"LlmBillingStateError",
"LlmHoldResult",
"LlmHoldValidation",
"start_hold",
"ensure_hold_exists",
"LlmChargeResult",
"LlmChargeValidation",
"charge_llm_credits",
"ensure_llm_charged",
"get_llm_ledger_states",
"validate_retryable_previous_attempt",
"log_provider_start",
"log_provider_success",
"log_provider_failure",
"record_provider_exception",
"log_celery_dispatch_start",
"log_celery_dispatch_success",
"log_celery_dispatch_failure",
"log_celery_dispatch_compensated",
"settle_success",
"release_on_failure",
"mark_business_success",
"refund_on_final_failure",
"finalize_llm_business_failure",
]
@@ -0,0 +1,305 @@
from __future__ import annotations
from typing import Any, Mapping
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
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
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)
if value is not None:
try:
return max(0, int(value))
except (TypeError, ValueError):
pass
return 0
async def create_call_attempt(
ctx: LlmBillingContext,
*,
detail: Mapping[str, Any] | None = None,
) -> str:
async with AsyncSessionLocal() as db:
async with db.begin():
result = await db.execute(
select(LlmBillingExecution)
.where(LlmBillingExecution.id == ctx.billing_execution_id)
.limit(1)
.with_for_update()
)
execution = result.scalar_one_or_none()
if execution is None:
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.CHARGED.value,
LlmBillingExecutionStatus.PRE_DEDUCTED.value, # 历史兼容
LlmBillingExecutionStatus.PROCESSING.value,
}:
raise LlmBillingStateError(f"LLM执行状态不允许调用模型:{execution.status}")
max_result = await db.execute(
select(func.coalesce(func.max(LlmCallAttempt.call_sequence), 0)).where(
LlmCallAttempt.billing_execution_id == execution.id
)
)
sequence = int(max_result.scalar_one() or 0) + 1
attempt = LlmCallAttempt(
id=generate_id(),
billing_execution_id=execution.id,
call_sequence=sequence,
retry_sequence=max(0, sequence - 1),
model_config_id=execution.model_config_id or ctx.model_config_id,
model_name_snapshot=execution.model_name_snapshot or ctx.model_name,
provider_snapshot=execution.provider_snapshot or ctx.provider,
request_started_at=utc_now(),
status=LlmCallAttemptStatus.STARTED.value,
postprocess_status="pending",
)
db.add(attempt)
execution.status = LlmBillingExecutionStatus.PROCESSING.value
execution.total_call_count += 1
ctx.current_call_attempt_id = attempt.id
# 同一个业务 execution 可能有多次供应商调用;每个新调用必须清空上一调用的
# 成功事实和用量快照,避免下一次真实 provider failure 被误判为后处理失败。
ctx.provider_call_succeeded = False
ctx.provider_usage_snapshot = None
ctx.token_usage_id = None
return attempt.id
async def _get_or_create_token_usage(
db: AsyncSession,
*,
ctx: LlmBillingContext,
attempt: LlmCallAttempt,
usage: Mapping[str, Any] | None,
input_tokens: int,
output_tokens: int,
total_tokens: int,
) -> tuple[TokenUsage, bool]:
"""先持久化 TokenUsage,再允许任何外键引用它。
LlmCallAttempt 仅保存 token_usage_id 字符串,没有 ORM relationship。SQLAlchemy
无法仅凭字符串赋值推导 INSERT/UPDATE 顺序,所以必须显式 flush TokenUsage。
同时按 user_id + biz_key 复用记录,保证审计重试幂等。
"""
biz_key = f"llm-call:{attempt.id}"
existing_result = await db.execute(
select(TokenUsage)
.where(TokenUsage.user_id == ctx.user_id, TokenUsage.biz_key == biz_key)
.limit(1)
)
existing = existing_result.scalar_one_or_none()
if existing is not None:
return existing, False
usage_row = TokenUsage(
id=generate_id(),
model_config_id=(usage or {}).get("model_config_id") or attempt.model_config_id,
user_id=ctx.user_id,
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
owner_type=ctx.owner_type,
owner_id=ctx.owner_id,
biz_key=biz_key,
source_module=ctx.source_module,
source_step_code=ctx.source_step_code,
)
try:
async with db.begin_nested():
db.add(usage_row)
# 关键:先确保 token_usage 已 INSERT,后续 attempt.token_usage_id UPDATE
# 才不会违反 llm_call_attempts_token_usage_id_fkey。
await db.flush([usage_row])
except IntegrityError:
# 并发或审计重试可能已经创建同一 biz_key,回查复用。
existing_result = await db.execute(
select(TokenUsage)
.where(TokenUsage.user_id == ctx.user_id, TokenUsage.biz_key == biz_key)
.limit(1)
)
existing = existing_result.scalar_one_or_none()
if existing is None:
raise
return existing, False
return usage_row, True
async def finish_call_success(
ctx: LlmBillingContext,
*,
usage: Mapping[str, Any] | None = None,
) -> None:
if not ctx.current_call_attempt_id:
await create_call_attempt(ctx)
now = utc_now()
input_tokens = _usage_int(usage, "input_tokens", "prompt_tokens")
output_tokens = _usage_int(usage, "output_tokens", "completion_tokens")
total_tokens = _usage_int(usage, "total_tokens") or input_tokens + output_tokens
token_usage_id: str | None = None
async with AsyncSessionLocal() as db:
async with db.begin():
result = await db.execute(
select(LlmCallAttempt)
.where(LlmCallAttempt.id == ctx.current_call_attempt_id)
.limit(1)
.with_for_update()
)
attempt = result.scalar_one_or_none()
if attempt is None:
raise LlmBillingStateError("LLM调用审计记录不存在")
if attempt.status == LlmCallAttemptStatus.SUCCEEDED.value:
ctx.token_usage_id = attempt.token_usage_id
return
previous_status = attempt.status
usage_row, token_usage_created = await _get_or_create_token_usage(
db,
ctx=ctx,
attempt=attempt,
usage=usage,
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
)
token_usage_id = usage_row.id
attempt.status = LlmCallAttemptStatus.SUCCEEDED.value
attempt.response_received_at = now
attempt.duration_ms = max(0, int((now - attempt.request_started_at).total_seconds() * 1000))
attempt.input_tokens = input_tokens
attempt.output_tokens = output_tokens
attempt.total_tokens = total_tokens
attempt.token_usage_id = token_usage_id
attempt.provider_request_id = (usage or {}).get("provider_request_id") or (usage or {}).get("request_id")
try:
attempt.http_status = int((usage or {}).get("http_status")) if (usage or {}).get("http_status") is not None else None
except (TypeError, ValueError):
attempt.http_status = None
# 支持把此前因审计异常误记的 failed 调用恢复为真实 succeeded。
attempt.error_message = None
attempt.token_unavailable_reason = None
execution_result = await db.execute(
select(LlmBillingExecution)
.where(LlmBillingExecution.id == attempt.billing_execution_id)
.limit(1)
.with_for_update()
)
execution = execution_result.scalar_one()
if previous_status in {LlmCallAttemptStatus.FAILED.value, LlmCallAttemptStatus.TIMEOUT.value}:
execution.failed_call_count = max(0, int(execution.failed_call_count or 0) - 1)
execution.successful_call_count += 1
execution.total_input_tokens += input_tokens
execution.total_output_tokens += output_tokens
execution.total_tokens += total_tokens
record_result = await db.execute(
select(CreditRecord)
.where(CreditRecord.id == execution.credit_record_id)
.limit(1)
.with_for_update()
)
record = record_result.scalar_one_or_none()
if record:
record.input_tokens = execution.total_input_tokens
record.output_tokens = execution.total_output_tokens
record.total_tokens = execution.total_tokens
record.llm_call_count = execution.total_call_count
record.llm_success_call_count = execution.successful_call_count
record.llm_failed_call_count = execution.failed_call_count
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:
if not ctx.current_call_attempt_id:
await create_call_attempt(ctx)
now = utc_now()
async with AsyncSessionLocal() as db:
async with db.begin():
result = await db.execute(
select(LlmCallAttempt)
.where(LlmCallAttempt.id == ctx.current_call_attempt_id)
.limit(1)
.with_for_update()
)
attempt = result.scalar_one_or_none()
if attempt is None:
return "missing"
if attempt.status == LlmCallAttemptStatus.SUCCEEDED.value:
# 供应商已成功并已记录 Token;后续解析/校验/落库失败只能记为后处理失败,
# 不能覆盖真实供应商成功事实,也不能重复累计失败调用次数。
attempt.postprocess_status = "failed"
attempt.postprocess_error = str(error)[:5000]
return "postprocess_failure"
if attempt.status != LlmCallAttemptStatus.STARTED.value:
return "noop"
attempt.status = LlmCallAttemptStatus.TIMEOUT.value if status == "timeout" else LlmCallAttemptStatus.FAILED.value
attempt.response_received_at = now
attempt.duration_ms = max(0, int((now - attempt.request_started_at).total_seconds() * 1000))
attempt.error_message = str(error)[:5000]
attempt.token_unavailable_reason = "供应商调用失败,未返回Token使用量"
execution_result = await db.execute(
select(LlmBillingExecution)
.where(LlmBillingExecution.id == attempt.billing_execution_id)
.limit(1)
.with_for_update()
)
execution = execution_result.scalar_one()
execution.failed_call_count += 1
record_result = await db.execute(select(CreditRecord).where(CreditRecord.id == execution.credit_record_id).limit(1).with_for_update())
record = record_result.scalar_one_or_none()
if record:
record.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
return "provider_failure"
+36 -129
View File
@@ -1,145 +1,52 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.llm_billing import LlmBillingConfigKey
from app.models.llm_billing.policy import LlmBillingPolicyModel
from app.services.llm_billing.context import LlmBillingPolicy
from app.services.system_config_cache import get_system_config_values
_DEFAULT_HOLD_CREDITS = 5.0
_FALSE_VALUES = {"0", "false", "no", "off", "disabled"}
_HOLD_CONFIG_KEYS = {
LlmBillingConfigKey.HOLD_DEFAULT.value,
LlmBillingConfigKey.HOLD_GENERATION_RECORD_PROMPT.value,
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value,
LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
LlmBillingConfigKey.LEGACY_OPTIMIZE_HOLD.value,
}
def _parse_bool(value: str | None, *, default: bool = True) -> bool:
if value is None or str(value).strip() == "":
return default
return str(value).strip().lower() not in _FALSE_VALUES
def _parse_float(value: str | float | int | None) -> float | None:
try:
if value is None or str(value).strip() == "":
return None
return round(float(value), 2)
except (TypeError, ValueError):
return None
def is_llm_hold_config_key(key: str | None) -> bool:
return bool(key and key in _HOLD_CONFIG_KEYS)
async def get_llm_billing_policy(
db: AsyncSession,
*,
config_key: str | None = None,
explicit_hold_credits: float | None = None,
default: float = _DEFAULT_HOLD_CREDITS,
scene_code: str | None = None,
) -> LlmBillingPolicy:
keys = [LlmBillingConfigKey.ENABLED.value]
if config_key:
keys.append(config_key)
keys.extend(
[
LlmBillingConfigKey.HOLD_DEFAULT.value,
LlmBillingConfigKey.LEGACY_OPTIMIZE_HOLD.value,
]
)
# 去重并保持优先级;一次读取避免 enabled/scene/default 分散查询。
ordered_keys = list(dict.fromkeys(keys))
values = await get_system_config_values(db, ordered_keys)
enabled = _parse_bool(values.get(LlmBillingConfigKey.ENABLED.value), default=True)
if not enabled:
return LlmBillingPolicy(enabled=False, hold_credits=0.0, config_key=config_key)
if explicit_hold_credits is not None:
amount = _parse_float(explicit_hold_credits)
source_key = "explicit"
else:
amount = None
source_key = None
for key in ordered_keys[1:]:
parsed = _parse_float(values.get(key))
if parsed is not None:
amount = parsed
source_key = key
break
if amount is None:
amount = round(float(default), 2)
source_key = "default"
if amount is None or amount <= 0:
"""按业务场景读取固定消费积分;不再读取旧 SystemConfig 冻结/预扣配置。"""
resolved_scene = scene_code
if not resolved_scene:
return LlmBillingPolicy(
enabled=True,
hold_credits=float(amount or 0),
config_key=config_key,
source_key=source_key,
charge_credits=0.0,
valid=False,
error="启用LLM统一计费时,预扣积分必须大于0",
error="LLM业务场景不能为空",
)
result = await db.execute(
select(LlmBillingPolicyModel)
.where(LlmBillingPolicyModel.scene_code == resolved_scene)
.limit(1)
)
model = result.scalar_one_or_none()
if model is None:
return LlmBillingPolicy(
charge_credits=0.0,
valid=False,
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(
charge_credits=amount,
valid=False,
error=f"LLM场景积分配置未启用或金额无效:{resolved_scene}",
policy_id=model.id,
version=model.version,
scene_name=model.scene_name,
)
return LlmBillingPolicy(
enabled=True,
hold_credits=round(float(amount), 2),
config_key=config_key,
source_key=source_key,
charge_credits=round(amount, 2),
valid=True,
policy_id=model.id,
version=model.version,
scene_name=model.scene_name,
)
async def get_llm_hold_credits(
db: AsyncSession,
*,
config_key: str | None = None,
default: float = _DEFAULT_HOLD_CREDITS,
) -> float:
policy = await get_llm_billing_policy(db, config_key=config_key, default=default)
return policy.hold_credits
async def is_llm_billing_enabled(db: AsyncSession) -> bool:
return (await get_llm_billing_policy(db)).enabled
async def validate_llm_system_config_value(
db: AsyncSession,
*,
key: str,
value: str,
) -> None:
"""校验后台单项更新,避免启用计费时保存零或负数预扣。"""
if key == LlmBillingConfigKey.ENABLED.value:
if not _parse_bool(value, default=True):
return
keys = [
LlmBillingConfigKey.HOLD_DEFAULT.value,
LlmBillingConfigKey.HOLD_GENERATION_RECORD_PROMPT.value,
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value,
LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
]
values = await get_system_config_values(db, keys, ttl_seconds=1)
invalid = [
config_name
for config_name in keys
if (raw_value := values.get(config_name)) is not None
and str(raw_value).strip() != ""
and ((parsed := _parse_float(raw_value)) is None or parsed <= 0)
]
if invalid:
raise ValueError(f"启用LLM统一计费前,请先将以下预扣配置设置为大于0:{', '.join(invalid)}")
return
if not is_llm_hold_config_key(key):
return
parsed = _parse_float(value)
enabled_values = await get_system_config_values(db, [LlmBillingConfigKey.ENABLED.value], ttl_seconds=1)
enabled = _parse_bool(enabled_values.get(LlmBillingConfigKey.ENABLED.value), default=True)
if enabled and (parsed is None or parsed <= 0):
raise ValueError("启用LLM统一计费时,预扣积分必须大于0")
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from app.enums.credit_record import CreditRecordChargeKind
from app.enums.llm_billing import LlmBillingLedgerState
@@ -8,25 +9,29 @@ 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):
"""供应商请求已成功并返回用量,但响应内容或业务结构处理失败。"""
def __init__(self, message: str, *, usage: dict | None = None) -> None:
super().__init__(message)
self.usage = dict(usage or {})
@dataclass(slots=True, frozen=True)
class LlmBillingPolicy:
enabled: bool
hold_credits: float
config_key: str | None = None
source_key: str | None = None
charge_credits: float
valid: bool = True
error: str | None = None
@property
def bypassed(self) -> bool:
return not self.enabled
policy_id: str | None = None
version: int | None = None
scene_name: str | None = None
@dataclass(slots=True)
@@ -42,35 +47,22 @@ class LlmBillingContext:
source_step_id: str | None = None
source_step_code: str | None = None
related_id: str | None = None
hold_credits: float | None = None
hold_config_key: str | None = None
description_prefix: str = "LLM"
trace_id: str | None = None
request_id: str | None = None
celery_task_id: str | None = None
provider: str | None = None
model_name: str | None = None
model_config_id: str | None = None
model_parameters_snapshot: dict | None = None
token_usage_id: str | None = None
@property
def hold_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="hold",
)
@property
def hold_release_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="hold_release",
)
# 供应商响应成功事实先于审计落库设置。即使审计事务暂时失败,
# 后续异常处理也不能把真实供应商成功覆盖成 provider failed。
provider_call_succeeded: bool = False
provider_usage_snapshot: dict | None = None
request_time: datetime | None = None
billing_execution_id: str | None = None
current_call_attempt_id: str | None = None
@property
def charge_biz_key(self) -> str:
@@ -79,35 +71,41 @@ class LlmBillingContext:
owner_id=self.owner_id,
attempt_no=self.attempt_no,
charge_kind=self.charge_kind,
action="charge",
# 使用独立 llm_charge 幂等键,避免与历史 Token 按量计费的 :charge 流水碰撞;
# CreditRecord 的业务动作仍然记录为 charge(真实消费)。
action="llm_charge",
)
@property
def ledger_biz_keys(self) -> tuple[str, str, str]:
return self.hold_biz_key, self.hold_release_biz_key, self.charge_biz_key
def refund_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="refund",
)
@property
def billing_biz_key(self) -> str:
return self.charge_biz_key
@dataclass(slots=True, frozen=True)
class LlmHoldResult:
class LlmChargeResult:
amount: float
state: LlmBillingLedgerState
created: bool = False
record_id: str | None = None
reason: str | None = None
@property
def bypassed(self) -> bool:
return self.state == LlmBillingLedgerState.BILLING_BYPASSED
execution_id: str | None = None
@dataclass(slots=True, frozen=True)
class LlmHoldValidation:
class LlmChargeValidation:
can_execute: bool
amount: float
state: LlmBillingLedgerState
reason: str | None = None
hold_record_id: str | None = None
@property
def bypassed(self) -> bool:
return self.state == LlmBillingLedgerState.BILLING_BYPASSED
charge_record_id: str | None = None
execution_id: str | None = None
@@ -0,0 +1,82 @@
from __future__ import annotations
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,
*,
page: int = 1,
page_size: int = 20,
scene_code: str | None = None,
status: str | None = None,
user_id: str | None = None,
) -> tuple[list[dict], int]:
filters = []
if scene_code:
filters.append(LlmBillingExecution.scene_code == scene_code)
if 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(
select(LlmBillingExecution)
.where(*filters)
.order_by(LlmBillingExecution.created_at.desc(), LlmBillingExecution.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
executions = list(result.scalars().all())
# 先收集 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_(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: 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
File diff suppressed because it is too large Load Diff
@@ -19,7 +19,7 @@ from app.enums.credit_record import (
CreditRecordChargeKind,
CreditRecordOwnerType,
)
from app.enums.llm_billing import LlmBillingConfigKey, LlmBillingLedgerState
from app.enums.llm_billing import LlmBillingLedgerState
from app.enums.hot_opening_replicate import HotOpeningStepCodeEnum, ModuleCodeEnum as HotModuleCodeEnum
from app.enums.shot_replicate import (
ModuleCodeEnum as ShotModuleCodeEnum,
@@ -38,8 +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, LlmHoldValidation, get_llm_ledger_states
from app.services.llm_billing.config import get_llm_billing_policy
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")
@@ -93,11 +92,6 @@ 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),
hold_config_key=(
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
if is_image_prompt
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
),
description_prefix="模块AI提词优化",
trace_id=f"module-recovery:{step.id}:attempt:{max(1, int(step.version or 1))}",
)
@@ -106,35 +100,24 @@ def _step_llm_billing_context(step: ModuleGenerationStep) -> LlmBillingContext:
async def _load_step_billing_validations(
db: AsyncSession,
steps: Iterable[ModuleGenerationStep],
) -> dict[str, LlmHoldValidation]:
) -> 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}
policies = {}
for config_key in {ctx.hold_config_key for ctx in contexts.values() if ctx.hold_config_key}:
policies[config_key] = await get_llm_billing_policy(db, config_key=config_key)
# 无论当前配置是否关闭,都批量读取历史 attempt 流水:运行中的 active HOLD
# 必须继续结算,不能因后台关闭计费而被当成 bypass 遗留冻结。
# 场景积分消费已经在 API 层完成;恢复任务只认同一业务 attempt 的账务执行记录,
# 不再读取旧 SystemConfig 冻结配置;缺少场景积分消费记录时直接终止恢复。
ledger_states = await get_llm_ledger_states(db, contexts.values())
output: dict[str, LlmHoldValidation] = {}
output: dict[str, LlmChargeValidation] = {}
for step_id, ctx in contexts.items():
policy = policies.get(ctx.hold_config_key)
ledger = ledger_states.get(
ctx.hold_biz_key,
LlmHoldValidation(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 and policy is not None and policy.bypassed:
output[step_id] = LlmHoldValidation(
True,
0.0,
LlmBillingLedgerState.BILLING_BYPASSED,
"billing_disabled",
)
elif ledger.state == LlmBillingLedgerState.MISSING and (policy is None or not policy.valid):
output[step_id] = LlmHoldValidation(
elif ledger.state == LlmBillingLedgerState.MISSING:
output[step_id] = LlmChargeValidation(
False,
0.0,
LlmBillingLedgerState.INVALID,
@@ -19,7 +19,6 @@ from app.enums.common import (
)
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType
from app.enums.generation_task import ChatGenerationTaskStatus
from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.shot_replicate import (
ShotSegmentReplicateStatusEnum,
ShotSplitStatusEnum,
@@ -51,13 +50,14 @@ 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_hold_exists,
ensure_llm_charged,
log_provider_failure,
log_provider_start,
log_provider_success,
release_on_failure,
settle_success,
start_hold,
mark_business_success,
charge_llm_credits,
record_provider_exception,
finalize_llm_business_failure,
)
from app.services.hot_opening_video_prompt_service import (
build_final_video_prompt,
@@ -369,8 +369,7 @@ def build_v2_video_prompt_billing_context(
source_step_id=str(step_id),
source_step_code=VIDEO_PROMPT_OPTIMIZE,
related_id=str(step_id),
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
description_prefix=f"{display_name}视频提词优化",
description_prefix=f"{display_name}视频提词优化",
trace_id=f"module-v2-video-prompt:{step_id}",
)
@@ -433,7 +432,7 @@ async def create_hot_opening_project_v2(
video_config=video_config,
target_platform=req.target_platform or "抖音",
)
await start_hold(
await charge_llm_credits(
db,
build_v2_video_prompt_billing_context(
user_id=str(current_user.id),
@@ -600,7 +599,7 @@ async def create_shot_replicate_project_v2(
urls=[req.material_image_url],
allow_common_migrate=True,
)
await start_hold(
await charge_llm_credits(
db,
build_v2_video_prompt_billing_context(
user_id=str(current_user.id),
@@ -724,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 start_hold(
await charge_llm_credits(
db,
build_v2_video_prompt_billing_context(
user_id=str(project.user_id),
@@ -799,19 +798,16 @@ async def mark_video_prompt_dispatch_failed_v2(
message=error_message,
detail={"dispatch_compensated": True},
)
await release_on_failure(
db,
build_v2_video_prompt_billing_context(
user_id=str(project.user_id),
project_id=str(project.id),
step_id=str(step.id),
step_version=int(step.version or 1),
module=config.module,
display_name=config.display_name,
),
error=error_message,
billing_context = build_v2_video_prompt_billing_context(
user_id=str(project.user_id),
project_id=str(project.id),
step_id=str(step.id),
step_version=int(step.version or 1),
module=config.module,
display_name=config.display_name,
)
await db.commit()
await finalize_llm_business_failure(billing_context, error=error_message)
async def run_video_prompt_optimize_v2(
@@ -888,14 +884,13 @@ async def run_video_prompt_optimize_v2(
source_step_id=project_snapshot["step_id"],
source_step_code=VIDEO_PROMPT_OPTIMIZE,
related_id=project_snapshot["step_id"],
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
description_prefix=f"{config.display_name}视频提词优化",
trace_id=f"module-v2-video-prompt:{project_snapshot['step_id']}",
)
hold_validation = await ensure_hold_exists(db, llm_billing_context)
if not hold_validation.can_execute:
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账务状态异常({hold_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
@@ -906,7 +901,7 @@ async def run_video_prompt_optimize_v2(
provider_succeeded = False
usage: dict[str, Any] = {}
log_provider_start(llm_billing_context, detail={"prompt_type": "video", "flow_version": "v2"})
await log_provider_start(db, llm_billing_context, detail={"prompt_type": "video", "flow_version": "v2"})
prompt_schema, final_prompt, usage = await optimize_hot_opening_video_prompt(
db,
user_id=project_snapshot["user_id"],
@@ -921,9 +916,11 @@ async def run_video_prompt_optimize_v2(
module=project_snapshot["module"],
project_id=project_snapshot["project_id"],
step_id=project_snapshot["step_id"],
fixed_model_config_id=llm_billing_context.model_config_id,
fixed_model_snapshot=llm_billing_context.model_parameters_snapshot,
)
provider_succeeded = True
log_provider_success(llm_billing_context, usage=usage)
await log_provider_success(db, llm_billing_context, usage=usage)
if execution_guard is not None:
await execution_guard()
@@ -945,24 +942,20 @@ async def run_video_prompt_optimize_v2(
row = locked.first()
if not row:
await db.rollback()
# Provider 已成功,即使业务对象被异常移除,也必须按真实 usage 完成幂等结算。
await settle_success(
db,
await log_provider_failure(db, llm_billing_context, error="业务对象已失效,供应商结果无法落库")
await finalize_llm_business_failure(
llm_billing_context,
usage=usage,
description=f"{config.display_name}-视频提词优化(业务对象失效结算)",
error="业务对象已失效,供应商结果无法落库",
)
await db.commit()
return None
project, step = row
if int(step.version) != expected_version or step.input_json != expected_input or step.status != ModuleStepStatusEnum.PROCESSING.value:
await settle_success(
db,
await db.rollback()
await log_provider_failure(db, llm_billing_context, error="业务步骤版本已失效,供应商结果被丢弃")
await finalize_llm_business_failure(
llm_billing_context,
usage=usage,
description=f"{config.display_name}-视频提词优化(失效结果结算)",
error="业务步骤版本已失效,供应商结果被丢弃",
)
await db.commit()
log_module_event_file(
module=project_snapshot["module"],
event_type=ModuleEventTypeEnum.STALE_STEP_RESULT_DISCARDED.value,
@@ -973,7 +966,7 @@ async def run_video_prompt_optimize_v2(
detail={"expected_version": expected_version},
)
return None
billing = await settle_success(
billing = await mark_business_success(
db,
llm_billing_context,
usage=usage,
@@ -1018,33 +1011,24 @@ async def run_video_prompt_optimize_v2(
)
await db.commit()
return step
except DatabaseRowLockBusy:
except DatabaseRowLockBusy as exc:
await db.rollback()
if locals().get("provider_succeeded", False):
await settle_success(
db,
if "llm_billing_context" in locals() and locals().get("provider_succeeded", False):
await log_provider_failure(db, llm_billing_context, error="业务行锁失败,供应商结果无法落库")
await finalize_llm_business_failure(
llm_billing_context,
usage=locals().get("usage") or {},
description=f"{config.display_name}-视频提词优化(行锁失败结算)",
error="业务行锁失败,供应商结果无法落库",
)
await db.commit()
return None
raise
raise exc
except Exception as exc:
await db.rollback()
if "llm_billing_context" in locals() and not locals().get("provider_succeeded", False):
log_provider_failure(llm_billing_context, error=str(exc))
if "llm_billing_context" in locals():
if locals().get("provider_succeeded", False):
await log_provider_failure(db, llm_billing_context, error=str(exc))
else:
provider_succeeded, usage = await record_provider_exception(db, llm_billing_context, exc)
try:
if "llm_billing_context" in locals():
if locals().get("provider_succeeded", False):
await settle_success(
db,
llm_billing_context,
usage=locals().get("usage") or {},
description=f"{config.display_name}-视频提词优化(本地失败结算)",
)
else:
await release_on_failure(db, llm_billing_context, error=str(exc))
if execution_guard is not None:
await execution_guard()
result = await execute_with_lock_timeout(
@@ -1070,9 +1054,9 @@ async def run_video_prompt_optimize_v2(
step.completed_at = utc_now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = str(exc) if str(exc) else type(exc).__name__
# provider 已成功时 settle_success 已在当前事务写入 RELEASE/CHARGE
# 即使业务步骤已不存在或已不是 processing,也必须提交账务结算。
await db.commit()
if "llm_billing_context" in locals():
await finalize_llm_business_failure(llm_billing_context, error=str(exc))
log_module_error(
module=row[0].module if row else "module_generation_v2",
event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value,
@@ -1092,8 +1076,11 @@ async def run_video_prompt_optimize_v2(
detail={"origin_error": str(exc), "provider_succeeded": locals().get("provider_succeeded", False)},
exc=mark_exc,
)
if locals().get("provider_succeeded", False):
raise
try:
if "llm_billing_context" in locals():
await finalize_llm_business_failure(llm_billing_context, error=str(exc))
except Exception:
pass
return None
File diff suppressed because it is too large Load Diff
@@ -12,7 +12,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType
from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.shot_replicate import ShotReplicateGenerationModeEnum, ShotReplicateStepCodeEnum, ModuleCodeEnum
from app.models.chat_generation_task import ChatGenerationTask
from app.models.module_generation_project import ModuleGenerationProject
@@ -59,13 +58,15 @@ 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_hold_exists,
ensure_llm_charged,
log_provider_failure,
record_provider_exception,
log_provider_start,
log_provider_success,
release_on_failure,
settle_success,
start_hold,
refund_on_final_failure,
finalize_llm_business_failure,
mark_business_success,
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,
@@ -841,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 start_hold(
await charge_llm_credits(
db,
LlmBillingContext(
user_id=str(project.user_id),
@@ -855,9 +856,8 @@ async def submit_image_prompt_optimize(
source_step_id=str(step.id),
source_step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
related_id=str(step.id),
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value,
description_prefix="拆镜复刻图片AI提词优化",
trace_id=f"llm-submit-hold:{step.id}",
trace_id=f"llm-submit-pre-deduct:{step.id}",
),
)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_SUBMITTED.value, message="图片 AI 提词任务已提交")
@@ -954,14 +954,13 @@ async def run_image_prompt_optimize(
source_step_id=step_id_value,
source_step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
related_id=step_id_value,
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value,
description_prefix="拆镜复刻图片AI提词优化",
description_prefix="拆镜复刻图片AI提词优化",
trace_id=f"shot-image-prompt:{step_id_value}",
)
hold_validation = await ensure_hold_exists(db, llm_billing_context)
if not hold_validation.can_execute:
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账务状态异常({hold_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
@@ -972,7 +971,7 @@ async def run_image_prompt_optimize(
provider_succeeded = False
token_usage: dict[str, Any] = {}
log_provider_start(llm_billing_context, detail={"prompt_type": "image"})
await log_provider_start(db, llm_billing_context, detail={"prompt_type": "image"})
try:
request_log = {"original_prompt": prompt_text, "references": references, "gen_type": "image"}
log_module_prompt_event(
@@ -997,9 +996,11 @@ async def run_image_prompt_optimize(
log_owner_type="module_generation_step",
log_owner_id=step_id_value,
generation_attempt_no=expected_step_version,
fixed_model_config_id=llm_billing_context.model_config_id,
fixed_model_snapshot=llm_billing_context.model_parameters_snapshot,
)
provider_succeeded = True
log_provider_success(llm_billing_context, usage=token_usage)
await log_provider_success(db, llm_billing_context, usage=token_usage)
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1013,15 +1014,10 @@ async def run_image_prompt_optimize(
expected_version=expected_step_version,
expected_input_json=expected_input_json,
):
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-图片AI提词优化(失效结果结算)",
)
await db.commit()
await log_provider_failure(db, llm_billing_context, error="当前步骤已失效,业务结果未采用")
await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务结果未采用")
return None
billing = await settle_success(
billing = await mark_business_success(
db,
llm_billing_context,
usage=token_usage,
@@ -1069,21 +1065,19 @@ async def run_image_prompt_optimize(
except DatabaseRowLockBusy:
await db.rollback()
if provider_succeeded:
# Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-图片AI提词优化(行锁失败结算)",
)
await db.commit()
await log_provider_failure(db, llm_billing_context, error="本地行锁冲突,业务结果未落库")
await finalize_llm_business_failure(llm_billing_context, error="本地行锁冲突,业务结果未落库")
return None
# Provider 尚未成功才允许同一 attempt 做系统自动重试。
raise
except Exception as exc:
await db.rollback()
if not provider_succeeded:
log_provider_failure(llm_billing_context, error=str(exc))
if provider_succeeded:
await log_provider_failure(db, llm_billing_context, error=str(exc))
else:
provider_succeeded, recovered_usage = await record_provider_exception(db, llm_billing_context, exc)
if recovered_usage:
token_usage = recovered_usage
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1098,16 +1092,7 @@ async def run_image_prompt_optimize(
expected_input_json=expected_input_json,
):
await db.rollback()
if provider_succeeded:
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-图片AI提词优化(异常失效结算)",
)
else:
await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分")
await db.commit()
await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务最终失败")
return None
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = str(exc) if str(exc) else type(exc).__name__
@@ -1126,16 +1111,8 @@ async def run_image_prompt_optimize(
)
_log_project_error(project=project, step=step, event_type="IMAGE_PROMPT_FAILED", message=project.error_message, exc=exc)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_FAILED.value, message=project.error_message)
if provider_succeeded:
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-图片AI提词优化(本地失败结算)",
)
else:
await release_on_failure(db, llm_billing_context, error=str(exc))
await db.commit()
await finalize_llm_business_failure(llm_billing_context, error=str(exc))
return step
@@ -1309,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 start_hold(
await charge_llm_credits(
db,
LlmBillingContext(
user_id=str(project.user_id),
@@ -1323,9 +1300,8 @@ async def submit_video_prompt_optimize(
source_step_id=str(step.id),
source_step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
related_id=str(step.id),
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
description_prefix="拆镜复刻视频AI提词优化",
trace_id=f"llm-submit-hold:{step.id}",
trace_id=f"llm-submit-pre-deduct:{step.id}",
),
)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_SUBMITTED.value, message="视频 AI 提词任务已提交")
@@ -1420,14 +1396,13 @@ async def run_video_prompt_optimize(
source_step_id=step_id_value,
source_step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
related_id=step_id_value,
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
description_prefix="拆镜复刻视频AI提词优化",
description_prefix="拆镜复刻视频AI提词优化",
trace_id=f"shot-video-prompt:{step_id_value}",
)
hold_validation = await ensure_hold_exists(db, llm_billing_context)
if not hold_validation.can_execute:
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账务状态异常({hold_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
@@ -1438,7 +1413,7 @@ async def run_video_prompt_optimize(
provider_succeeded = False
token_usage: dict[str, Any] = {}
log_provider_start(llm_billing_context, detail={"prompt_type": "video"})
await log_provider_start(db, llm_billing_context, detail={"prompt_type": "video"})
try:
request_log = {
"source_project_name": material.get("source_project_name") or "",
@@ -1475,9 +1450,11 @@ async def run_video_prompt_optimize(
project_id=project_id_value,
step_id=step_id_value,
trace_id=f"shot-video-prompt:{step_id_value}",
fixed_model_config_id=llm_billing_context.model_config_id,
fixed_model_snapshot=llm_billing_context.model_parameters_snapshot,
)
provider_succeeded = True
log_provider_success(llm_billing_context, usage=token_usage)
await log_provider_success(db, llm_billing_context, usage=token_usage)
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1491,15 +1468,10 @@ async def run_video_prompt_optimize(
expected_version=expected_step_version,
expected_input_json=expected_input_json,
):
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-视频AI提词优化(失效结果结算)",
)
await db.commit()
await log_provider_failure(db, llm_billing_context, error="当前步骤已失效,业务结果未采用")
await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务结果未采用")
return None
billing = await settle_success(
billing = await mark_business_success(
db,
llm_billing_context,
usage=token_usage,
@@ -1550,21 +1522,19 @@ async def run_video_prompt_optimize(
except DatabaseRowLockBusy:
await db.rollback()
if provider_succeeded:
# Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-视频AI提词优化(行锁失败结算)",
)
await db.commit()
await log_provider_failure(db, llm_billing_context, error="本地行锁冲突,业务结果未落库")
await finalize_llm_business_failure(llm_billing_context, error="本地行锁冲突,业务结果未落库")
return None
# Provider 尚未成功才允许同一 attempt 做系统自动重试。
raise
except Exception as exc:
await db.rollback()
if not provider_succeeded:
log_provider_failure(llm_billing_context, error=str(exc))
if provider_succeeded:
await log_provider_failure(db, llm_billing_context, error=str(exc))
else:
provider_succeeded, recovered_usage = await record_provider_exception(db, llm_billing_context, exc)
if recovered_usage:
token_usage = recovered_usage
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1579,16 +1549,7 @@ async def run_video_prompt_optimize(
expected_input_json=expected_input_json,
):
await db.rollback()
if provider_succeeded:
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-视频AI提词优化(异常失效结算)",
)
else:
await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分")
await db.commit()
await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务最终失败")
return None
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = str(exc) if str(exc) else type(exc).__name__
@@ -1607,16 +1568,8 @@ async def run_video_prompt_optimize(
)
_log_project_error(project=project, step=step, event_type="VIDEO_PROMPT_FAILED", message=project.error_message, exc=exc)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value, message=project.error_message)
if provider_succeeded:
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-视频AI提词优化(本地失败结算)",
)
else:
await release_on_failure(db, llm_billing_context, error=str(exc))
await db.commit()
await finalize_llm_business_failure(llm_billing_context, error=str(exc))
return step
@@ -1937,7 +1890,7 @@ async def mark_shot_replicate_step_dispatch_failed(
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = error_message
if step.step_code in (ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value):
await release_on_failure(
await refund_on_final_failure(
db,
LlmBillingContext(
user_id=str(project.user_id),
@@ -1955,11 +1908,6 @@ async def mark_shot_replicate_step_dispatch_failed(
source_step_id=str(step.id),
source_step_code=str(step.step_code),
related_id=str(step.id),
hold_config_key=(
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
if step.step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
),
description_prefix=(
"拆镜复刻图片AI提词优化"
if step.step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.celery_queue import CeleryQueue
from app.enums.llm_billing import LlmBillingConfigKey, LlmBillingLedgerState
from app.enums.llm_billing import LlmBillingLedgerState
from app.enums.shot_replicate import ShotSplitStatusEnum
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.shot_replicate_task_set import ShotReplicateTaskSet
@@ -19,7 +19,6 @@ from app.services.shot_replicate_taskset_service import (
refresh_task_set_split_summaries,
)
from app.services.llm_billing import get_llm_ledger_states
from app.services.llm_billing.config import get_llm_billing_policy
from app.services.celery_runtime.runtime_service import runtime_lock_values
from app.tasks.celery_app import celery_app
@@ -230,15 +229,11 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]:
)
segments = list(segment_result.scalars().all())
billing_policy = await get_llm_billing_policy(
db,
config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
)
billing_contexts = [
*(build_task_set_analysis_billing_context(item) for item in task_sets),
*(build_segment_analysis_billing_context(item) for item in segments),
]
# 配置关闭后仍需识别并继续处理已存在的 active HOLD;只有 missing 流水才按 bypass
# 场景积分消费配置变更后仍需识别并继续处理已存在的有效消费;缺少消费记录时禁止恢复
billing_states = await get_llm_ledger_states(db, billing_contexts)
lock_keys: list[str] = []
@@ -264,13 +259,10 @@ 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.hold_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 and billing_policy.bypassed:
can_execute = True
state = LlmBillingLedgerState.BILLING_BYPASSED.value
elif validation and validation.state == LlmBillingLedgerState.MISSING and not billing_policy.valid:
if validation and validation.state == LlmBillingLedgerState.MISSING:
state = LlmBillingLedgerState.INVALID.value
if not can_execute:
item.analysis_status = ShotAnalysisStatusEnum.FAILED.value
@@ -297,13 +289,10 @@ 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.hold_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 and billing_policy.bypassed:
can_execute = True
state = LlmBillingLedgerState.BILLING_BYPASSED.value
elif validation and validation.state == LlmBillingLedgerState.MISSING and not billing_policy.valid:
if validation and validation.state == LlmBillingLedgerState.MISSING:
state = LlmBillingLedgerState.INVALID.value
if not can_execute:
item.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
@@ -16,7 +16,6 @@ from app.enums.credit_record import (
CreditRecordSourceModule,
CreditRecordSourceStepCode,
)
from app.enums.llm_billing import LlmBillingConfigKey
from sqlalchemy import String, case, cast, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -56,8 +55,8 @@ from app.schemas.shot_replicate import (
from app.services.module_generation_log_service import log_module_event_file
from app.services.llm_billing import (
LlmBillingContext,
release_on_failure,
start_hold,
refund_on_final_failure,
charge_llm_credits,
validate_retryable_previous_attempt,
)
from app.services.resource_accounting_service import SOURCE_MODEL_SHOT_SEGMENT, soft_delete_resources_by_source
@@ -94,8 +93,7 @@ def build_task_set_analysis_billing_context(task_set: ShotReplicateTaskSet) -> L
source_step_id=str(task_set.id),
source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value,
related_id=str(task_set.id),
hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
description_prefix="拆镜复刻原视频AI分析",
description_prefix="拆镜复刻原视频AI分析",
trace_id=f"shot-task-set-analysis:{task_set.id}:attempt:{int(task_set.analysis_attempt_no or 1)}",
)
@@ -113,8 +111,7 @@ def build_segment_analysis_billing_context(segment: ShotReplicateSegment) -> Llm
source_step_id=str(segment.id),
source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value,
related_id=str(segment.id),
hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
description_prefix="拆镜复刻片段视频AI分析",
description_prefix="拆镜复刻片段视频AI分析",
trace_id=f"shot-segment-analysis:{segment.id}:attempt:{int(segment.analysis_attempt_no or 1)}",
)
@@ -247,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)
@@ -268,7 +265,7 @@ async def create_task_set(
)
db.add(task_set)
await db.flush()
await start_hold(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",
@@ -686,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,
@@ -751,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 start_hold(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,10 +917,10 @@ async def delete_segment(
raise HTTPException(status_code=409, detail="当前拆镜片段关联的复刻流程正在处理中,暂不能删除")
if segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value:
await release_on_failure(
await refund_on_final_failure(
db,
build_segment_analysis_billing_context(segment),
error="用户删除自定义拆镜片段,释放未结算的片段分析预扣",
error="用户删除自定义拆镜片段,退回片段分析消费积分",
)
deleted_at = _now()
@@ -982,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_hold_release_on_cancel": segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value,
"llm_charge_refund_on_cancel": segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value,
},
)
@@ -1037,17 +1034,17 @@ async def delete_task_set(
# 删除是对未执行/失败任务的最终取消动作;处理中的任务已在上方拦截。
# 这里仅做账务补偿,不改变现有逐项目删除流程。
await release_on_failure(
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 release_on_failure(
await refund_on_final_failure(
db,
build_segment_analysis_billing_context(segment),
error="用户删除拆镜任务集,释放未结算的片段分析预扣",
error="用户删除拆镜任务集,退回片段分析消费积分",
)
segment_ids = [segment.id for segment in segments]
@@ -1113,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_hold_release_on_cancel": True,
"llm_charge_refund_on_cancel": True,
},
)
return ShotTaskSetDeleteOut(
@@ -1198,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 start_hold(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,
@@ -1304,7 +1301,7 @@ async def prepare_reanalyze_segment(
segment.segment_category = None
segment.segment_audience = None
await db.flush()
await start_hold(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,
@@ -1357,7 +1354,7 @@ async def mark_task_set_analysis_dispatch_failed(
task_set.analysis_claim_token = None
task_set.analysis_lease_until = None
task_set.analysis_error_message = error_message
await release_on_failure(db, build_task_set_analysis_billing_context(task_set), error=error_message)
await refund_on_final_failure(db, build_task_set_analysis_billing_context(task_set), error=error_message)
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
@@ -1394,7 +1391,7 @@ async def mark_segment_analysis_dispatch_failed(
segment.analysis_claim_token = None
segment.analysis_lease_until = None
segment.analysis_error_message = error_message
await release_on_failure(db, build_segment_analysis_billing_context(segment), error=error_message)
await refund_on_final_failure(db, build_segment_analysis_billing_context(segment), error=error_message)
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
@@ -1423,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 的冻结状态。用户重试切片时继续沿用
# 原 active HOLD;只有片段分析最终失败或用户删除片段时才释放
# 原场景积分消费;只有片段分析最终失败或用户删除片段时才按原来源退款
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
@@ -17,6 +17,7 @@ from app.services.resource_signed_url_service import build_resource_signed_url
from app.enums.common import LogEventStatusEnum, LogSourceEnum
from app.enums.shot_replicate import ModuleCodeEnum, ShotReplicateLogEventEnum, ShotReplicateRemoteActionEnum
from app.services.operation_log_service import log_ai_model_event
from app.services.llm_billing.context import LlmProviderPostprocessError
from app.utils.id_gen import generate_id
AnalysisMode = Literal["full_breakdown", "summary_only"]
@@ -411,13 +412,15 @@ def filter_and_normalize_breakdown(result: dict[str, Any], *, mode: AnalysisMode
return result
async def _select_model_config(db: AsyncSession) -> ModelConfig | None:
result = await db.execute(
select(ModelConfig)
.where(ModelConfig.is_active == True, ModelConfig.deleted_at.is_(None))
.order_by(ModelConfig.priority.desc())
.limit(1)
async def _select_model_config(db: AsyncSession, model_config_id: str | None = None) -> ModelConfig | None:
stmt = select(ModelConfig).where(
ModelConfig.is_active == True,
ModelConfig.deleted_at.is_(None),
ModelConfig.provider != "mock",
)
if model_config_id:
stmt = stmt.where(ModelConfig.id == model_config_id)
result = await db.execute(stmt.order_by(ModelConfig.priority.desc(), ModelConfig.id.asc()).limit(1))
return result.scalar_one_or_none()
@@ -559,6 +562,8 @@ async def analyze_video_for_shot_split(
task_set_id: str | None = None,
segment_id: str | None = None,
trace_id: str | None = None,
fixed_model_config_id: str | None = None,
fixed_model_snapshot: dict[str, Any] | None = None,
) -> ShotVideoAnalysisResult:
"""调用模型完成拆镜/片段分析。
@@ -568,19 +573,20 @@ async def analyze_video_for_shot_split(
"""
trace_id = trace_id or generate_id()
call_id = generate_id()
config_row = await _select_model_config(db)
config_row = await _select_model_config(db, fixed_model_config_id)
if not config_row:
raise RuntimeError("拆镜分析模型未配置:请先在 model_configs 表启用可用模型")
# 外部调用前转换为纯数据快照,随后释放数据库事务,避免一小时 HTTP 请求期间 idle in transaction。
snapshot = dict(fixed_model_snapshot or {})
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 ""),
name=str(snapshot.get("name") or config_row.name or ""),
provider=str(snapshot.get("provider") or config_row.provider or ""),
api_base=str(snapshot.get("api_base") or config_row.api_base or ""),
api_key=str(config_row.api_key or ""),
model_name=str(config_row.model_name or ""),
max_tokens=getattr(config_row, "max_tokens", None),
temperature=getattr(config_row, "temperature", None),
model_name=str(snapshot.get("model_name") or config_row.model_name or ""),
max_tokens=snapshot.get("max_tokens") if snapshot.get("max_tokens") is not None else getattr(config_row, "max_tokens", None),
temperature=snapshot.get("temperature") if snapshot.get("temperature") is not None else getattr(config_row, "temperature", None),
)
if not str(config.api_key or "").strip():
raise RuntimeError(f"拆镜分析模型 API Key 为空: model_config_id={config.id}")
@@ -610,8 +616,8 @@ async def analyze_video_for_shot_split(
{"role": "system", "content": system_prompt},
user_message,
],
"max_tokens": int(getattr(settings, "SHOT_ANALYSIS_MAX_TOKENS", 5000) or getattr(config, "max_tokens", 5000) or 5000),
"temperature": float(getattr(settings, "SHOT_ANALYSIS_TEMPERATURE", 0.1) or getattr(config, "temperature", 0.1) or 0.1),
"max_tokens": int((getattr(settings, "SHOT_ANALYSIS_MAX_TOKENS", 5000) or 5000)),
"temperature": float((getattr(settings, "SHOT_ANALYSIS_TEMPERATURE", 0.1) or 0.1)),
"response_format": {"type": "json_object"},
}
log_request_data: dict[str, Any] = {
@@ -729,33 +735,6 @@ async def analyze_video_for_shot_split(
)
raise
try:
content = get_message_content_or_raise(raw)
result = parse_model_json(content)
except Exception as exc:
event_type = ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_EMPTY.value if "content 为空" in str(exc) else ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_PARSE_FAILED.value
_log_shot_ai_model_event(
call_id=call_id,
event_type=event_type,
event_status=LogEventStatusEnum.FAILED.value,
config=config,
trace_id=trace_id,
user_id=user_id,
task_set_id=task_set_id,
segment_id=segment_id,
mode=mode,
request_data={**log_request_data, "api_url": url},
response_data=raw,
http_status=response.status_code,
message="拆镜视频分析模型内容解析失败",
error=str(exc),
)
raise
result = fill_none_with_wu(result)
result = ensure_result_schema(result)
result = filter_and_normalize_breakdown(result, mode=mode)
raw_usage = raw.get("usage")
usage_reported = bool(
isinstance(raw_usage, dict)
@@ -777,6 +756,8 @@ async def analyze_video_for_shot_split(
"analysis_mode": mode,
"trace_id": trace_id,
"usage_reported": usage_reported,
"provider_request_id": str(raw.get("id") or remote_request_id or "") or None,
"http_status": int(response.status_code),
}
if not token_usage["total_tokens"]:
token_usage["total_tokens"] = token_usage["input_tokens"] + token_usage["output_tokens"]
@@ -788,6 +769,33 @@ async def analyze_video_for_shot_split(
"model_name": config.model_name,
})
try:
content = get_message_content_or_raise(raw)
result = parse_model_json(content)
result = fill_none_with_wu(result)
result = ensure_result_schema(result)
result = filter_and_normalize_breakdown(result, mode=mode)
except Exception as exc:
event_type = ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_EMPTY.value if "content 为空" in str(exc) else ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_PARSE_FAILED.value
_log_shot_ai_model_event(
call_id=call_id,
event_type=event_type,
event_status=LogEventStatusEnum.FAILED.value,
config=config,
trace_id=trace_id,
user_id=user_id,
task_set_id=task_set_id,
segment_id=segment_id,
mode=mode,
request_data={**log_request_data, "api_url": url},
response_data=raw,
token_usage=token_usage,
http_status=response.status_code,
message="拆镜视频分析模型内容后处理失败",
error=str(exc),
)
raise LlmProviderPostprocessError(f"拆镜视频分析响应后处理失败: {exc}", usage=token_usage) from exc
_log_shot_ai_model_event(
call_id=call_id,
event_type=(
@@ -1,25 +1,15 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_record import (
CreditRecordBillingScene,
CreditRecordChargeKind,
CreditRecordSubject,
CreditRecordSourceModule,
)
from app.enums.team import TeamStatus
from app.enums.user import UserType
from app.models.team import Team
from app.models.user import User
from app.services.credits import add_credits, deduct_credits
from app.services.credit_record_meta_service import CreditRecordMeta
from app.utils.id_gen import generate_id
from app.services.credit.query_service import attach_credit_snapshot, get_user_credit_map
async def set_team_manager(db: AsyncSession, team_id: str, user_id: str | None) -> Team:
@@ -114,6 +104,9 @@ async def get_team_members(
.limit(page_size)
)
members = list(result.scalars().all())
credit_map = await get_user_credit_map(db, [item.id for item in members])
for item in members:
attach_credit_snapshot(item, credit_map.get(item.id, 0.0))
return {
"items": [
@@ -139,104 +132,4 @@ async def transfer_credits_to_member(
direction: str = "increase", # "increase" 管理人→成员; "decrease" 成员扣减
description: str | None = None,
) -> None:
"""管理人为团队成员增加或扣减积分。
direction:
- "increase": 管理人从自己余额转积分给成员管理人减少成员增加
- "decrease": 从成员扣积分回到管理人成员减少管理人增加
"""
if amount <= 0:
raise HTTPException(status_code=400, description="积分数量必须大于0")
if direction not in ("increase", "decrease"):
raise HTTPException(status_code=400, description="无效操作方向")
# 获取管理人
manager_result = await db.execute(
select(User).where(User.id == manager_id, User.is_active.is_(True)).limit(1)
)
manager = manager_result.scalar_one_or_none()
if not manager:
raise HTTPException(status_code=404, detail="管理员不存在")
# 获取目标成员
member_result = await db.execute(
select(User).where(User.id == target_member_id, User.is_active.is_(True)).limit(1)
)
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="成员不存在")
# 验证管理人是该团队的管理人且目标是同团队成员
if not manager.team_id:
raise HTTPException(status_code=400, detail="您不在任何团队中")
if member.team_id != manager.team_id:
raise HTTPException(status_code=400, detail="只能操作同团队成员")
team_result = await db.execute(
select(Team).where(Team.id == manager.team_id, Team.deleted_at.is_(None)).limit(1)
)
team = team_result.scalar_one_or_none()
if not team or team.manager_id != manager_id:
raise HTTPException(status_code=403, detail="只有团队管理人才能分配积分")
# 禁止管理人给自己转积分
if target_member_id == manager_id:
raise HTTPException(status_code=400, detail="不能给自己调整积分")
desc = description or ("团队积分发放" if direction == "increase" else "团队积分扣减")
xfer_id = generate_id()
# 构建团队内部转账的 meta,确保 team_id_snapshot 等字段被正确设置
def _build_team_transfer_meta(uid: str) -> CreditRecordMeta:
meta = CreditRecordMeta(
owner_type="team_internal_transfer",
owner_id=xfer_id,
charge_kind=CreditRecordChargeKind.TEAM_INTERNAL.value,
credit_subject=CreditRecordSubject.TEAM_INTERNAL.value,
source_module=CreditRecordSourceModule.TEAM.value,
billing_scene=CreditRecordBillingScene.TEAM_INTERNAL_TRANSFER.value,
)
return meta
if direction == "increase":
# 管理人扣减
await deduct_credits(
db,
manager_id,
amount,
f"分配给成员 {member.username}: {desc}",
record_type="team_internal",
biz_key=f"mgr_xfer_out:{manager_id}:{target_member_id}:{xfer_id}",
record_meta=_build_team_transfer_meta(manager_id),
)
# 成员增加
await add_credits(
db,
target_member_id,
amount,
f"来自团队管理人: {desc}",
record_type="team_internal",
biz_key=f"mgr_xfer_in:{manager_id}:{target_member_id}:{xfer_id}",
record_meta=_build_team_transfer_meta(target_member_id),
)
else:
# 成员扣减
await deduct_credits(
db,
target_member_id,
amount,
f"扣减给团队管理人: {desc}",
record_type="team_internal",
biz_key=f"mgr_deduct_out:{manager_id}:{target_member_id}:{xfer_id}",
record_meta=_build_team_transfer_meta(target_member_id),
)
# 管理人增加
await add_credits(
db,
manager_id,
amount,
f"来自成员 {member.username}: {desc}",
record_type="team_internal",
biz_key=f"mgr_deduct_in:{manager_id}:{target_member_id}:{xfer_id}",
record_meta=_build_team_transfer_meta(manager_id),
)
raise HTTPException(status_code=409, detail="当前版本积分暂未开放团队转账功能")
@@ -64,20 +64,20 @@ class DownloadedAsset(NamedTuple):
def _max_bytes(asset_type: str) -> int:
return VP_V3_VIDEO_MAX_BYTES if asset_type == UploadResourceTypeEnum.VIDEO.value else VP_V3_IMAGE_MAX_BYTES
return VP_V3_VIDEO_MAX_BYTES if asset_type == UploadResourceTypeEnum.F_VIDEO.value else VP_V3_IMAGE_MAX_BYTES
def _allowed_mime_set(asset_type: str) -> set[str]:
return VIDEO_MIME_ALLOWED if asset_type == UploadResourceTypeEnum.VIDEO.value else IMAGE_MIME_ALLOWED
return VIDEO_MIME_ALLOWED if asset_type == UploadResourceTypeEnum.F_VIDEO.value else IMAGE_MIME_ALLOWED
def _safe_ext(filename: str, asset_type: str) -> str:
ext = (os.path.splitext(filename or "")[1].lower().lstrip(".") or "").strip()
allowed = VIDEO_EXT_ALLOWED if asset_type == UploadResourceTypeEnum.VIDEO.value else IMAGE_EXT_ALLOWED
allowed = VIDEO_EXT_ALLOWED if asset_type == UploadResourceTypeEnum.F_VIDEO.value else IMAGE_EXT_ALLOWED
if ext and ext in allowed:
return ext
# fallback
return "mp4" if asset_type == UploadResourceTypeEnum.VIDEO.value else "png"
return "mp4" if asset_type == UploadResourceTypeEnum.F_VIDEO.value else "png"
def _get_ffprobe_bin() -> str:
@@ -150,7 +150,7 @@ def _build_destination(*, api_key_id: str, asset_type: str, original_filename: s
month = f"{now.month:02d}"
day = f"{now.day:02d}"
sub_type = "videos" if asset_type == UploadResourceTypeEnum.VIDEO.value else "images"
sub_type = "videos" if asset_type == UploadResourceTypeEnum.F_VIDEO.value else "images"
rel_dir = Path("api") / "private_portrait_virtual" / sub_type / year / month / day
filename = f"vp_v3_{safe_uuid}.{ext}"
@@ -198,7 +198,7 @@ def _guess_ext_from_mime(mime: str | None, asset_type: str) -> str | None:
if not mime:
return None
# 按 asset_type 优先匹配
allowed_exts = VIDEO_EXT_ALLOWED if asset_type == UploadResourceTypeEnum.VIDEO.value else IMAGE_EXT_ALLOWED
allowed_exts = VIDEO_EXT_ALLOWED if asset_type == UploadResourceTypeEnum.F_VIDEO.value else IMAGE_EXT_ALLOWED
guesses = mimetypes.guess_all_extensions(mime.strip().lower()) or []
for g in guesses:
ext = g.lower().lstrip(".")
@@ -232,7 +232,7 @@ async def upload_asset_file(
- 落盘到 /uploads/images|videos/vp_v3/{api_key_id_short}/{yyyy}/{mm}/{dd}/
- 返回 url + 虚拟 resource_idhash 形式
"""
if asset_type not in {UploadResourceTypeEnum.IMAGE.value, UploadResourceTypeEnum.VIDEO.value}:
if asset_type not in {UploadResourceTypeEnum.F_IMAGE.value, UploadResourceTypeEnum.F_VIDEO.value}:
raise HTTPException(status_code=400, detail="虚拟素材上传仅支持图片或视频")
max_size = _max_bytes(asset_type)
@@ -317,7 +317,7 @@ async def download_url_to_local(
- 下载一半失败 清理临时文件不留下半截
- 视频可选探测 ffprobe失败不抛错调用方自行用 payload.video_duration
"""
if asset_type not in {UploadResourceTypeEnum.IMAGE.value, UploadResourceTypeEnum.VIDEO.value}:
if asset_type not in {UploadResourceTypeEnum.F_IMAGE.value, UploadResourceTypeEnum.F_VIDEO.value}:
raise HTTPException(status_code=400, detail="虚拟素材仅支持图片或视频")
# URL 合法性
@@ -465,7 +465,7 @@ async def download_url_to_local(
# --- 第二步:视频可选 ffprobe 探测时长 ---
duration: float | None = None
if asset_type == UploadResourceTypeEnum.VIDEO.value:
if asset_type == UploadResourceTypeEnum.F_VIDEO.value:
duration = _probe_duration_optional(Path(temp_path))
# --- 第三步:落到最终目录(与 _build_destination 一致的目录结构/权限) ---
@@ -483,7 +483,7 @@ async def download_url_to_local(
if not mime:
mime, _ = mimetypes.guess_type(final_filename)
if not mime:
mime = "image/png" if asset_type == UploadResourceTypeEnum.IMAGE.value else "video/mp4"
mime = "image/png" if asset_type == UploadResourceTypeEnum.F_IMAGE.value else "video/mp4"
suggested_name: str | None = None
try: