Merge branch 'main' of https://gitee.com/wg123/video-gen
This commit is contained in:
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
@@ -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_id(hash 形式)
|
||||
"""
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user