积分冻结释放

This commit is contained in:
2026-07-24 09:18:05 +08:00
parent 920d884e92
commit 68e902b4a4
38 changed files with 4743 additions and 391 deletions
@@ -21,7 +21,7 @@ from app.services.credit_record_meta_service import (
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
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
@@ -38,7 +38,7 @@ OWNER_SHOT_REPLICATE_TASK_SET = CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.va
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)$"
r"^(?P<owner_type>[^:]+):(?P<owner_id>[^:]+):attempt:(?P<attempt_no>\d+):(?P<charge_kind>[^:]+):(?P<action>charge|refund|hold|hold_release)$"
)
@@ -63,7 +63,8 @@ class BillingSummary:
return round(sum(item.amount for item in self.items if item.charged), 2)
def get_amount(self, charge_key: str) -> float:
return round(sum(item.amount for item in self.items if item.charge_key == charge_key and item.charged), 2)
"""返回该业务动作的已记录金额;幂等重放命中旧流水时也返回真实金额。"""
return round(sum(item.amount for item in self.items if item.charge_key == charge_key), 2)
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
@@ -100,8 +101,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"):
raise ValueError("action 仅支持 charge/refund")
if action not in ("charge", "refund", "hold", "hold_release"):
raise ValueError("action 仅支持 charge/refund/hold/hold_release")
if attempt_no <= 0:
raise ValueError("attempt_no 必须大于 0")
return f"{owner_type}:{owner_id}:attempt:{attempt_no}:{charge_kind}:{action}"
@@ -139,15 +140,6 @@ async def _calc_optional_token_credits(db: AsyncSession, tokens: int, config_key
return round(tokens * rate / 1000, 2)
async def _find_existing_by_biz_key(db: AsyncSession, *, user_id: str, biz_key: str) -> CreditRecord | 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 get_next_credit_attempt_no(
db: AsyncSession,
*,
@@ -185,29 +177,25 @@ async def deduct_credits_locked_once(
biz_key: str | None = None,
attempt_no: int | None = None,
record_meta: CreditRecordMeta | dict | None = None,
allow_negative: bool = False,
) -> BillingItem:
"""按 biz_key 做幂等扣费。
charge_key 只保留为业务分类;正式幂等以 biz_key 为准。
record_meta 负责把业务归属、模块、步骤、token、模型快照写入 CreditRecord
幂等判断、用户行锁、余额更新和流水写入由 deduct_credits_result 在同一短事务内完成,
避免先查一次 biz_key、加锁后再查一次的重复 SQL 和竞态窗口
"""
amount = _round2(amount)
if amount <= 0:
return BillingItem(charge_key=charge_key, amount=0.0, charged=False, skipped_reason="amount_lte_zero", biz_key=biz_key, attempt_no=attempt_no)
return BillingItem(
charge_key=charge_key,
amount=0.0,
charged=False,
skipped_reason="amount_lte_zero",
biz_key=biz_key,
attempt_no=attempt_no,
)
if biz_key:
existing_charge = await _find_existing_by_biz_key(db, user_id=user_id, biz_key=biz_key)
if existing_charge:
return BillingItem(
charge_key=charge_key,
amount=abs(_round2(existing_charge.amount)),
charged=False,
skipped_reason="already_charged",
biz_key=biz_key,
attempt_no=attempt_no,
)
await deduct_credits(
mutation = await deduct_credits_result(
db,
user_id=user_id,
amount=amount,
@@ -215,8 +203,16 @@ async def deduct_credits_locked_once(
related_id=related_id,
biz_key=biz_key,
record_meta=record_meta,
allow_negative=allow_negative,
)
return BillingItem(
charge_key=charge_key,
amount=mutation.amount if not mutation.created else amount,
charged=mutation.created,
skipped_reason=None if mutation.created else "already_charged",
biz_key=biz_key,
attempt_no=attempt_no,
)
return BillingItem(charge_key=charge_key, amount=amount, charged=True, biz_key=biz_key, attempt_no=attempt_no)
async def charge_chatapi_prompt_usage(