Files
meida_backend/packages/billing/service.py
T
xsl 7c67506474 feat(domains): identity, billing, task services and domain placeholders
Billing implements reserve -> commit -> refund against bl_quota and bl_ledger
with row-level locking and idempotency keys; the refund commits on its own
transaction so a failed request cannot silently keep a user's quota. Task
implements the unified task centre over the state machine. Identity issues and
resolves users. Remaining domains are placeholders whose READMEs state what
belongs there and what must not.
2026-08-14 00:26:39 +08:00

268 lines
9.6 KiB
Python
Executable File

"""Quota three-phase protocol: reserve → commit → refund (架构文档 §7.4).
为什么预扣而不是直接扣: 长耗时任务可能失败,直接扣会产生「扣了额度没拿到
结果」的客诉。V1 没有客服退款兜底手段,只能靠机制保证 (FR-BILL-05).
Nothing here decides *how much* a feature costs — that comes from
``bl_quota_rule`` so it can change without a release (FR-BILL-04).
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import cast
from sqlalchemy.ext.asyncio import AsyncSession
from packages.billing import repo
from packages.domain.billing import quota as quota_rules
from packages.domain.errors import QuotaExceededError
from packages.domain.ids import next_id
from packages.shared.errors import AppError, ErrorCode, QuotaExhaustedError
from packages.shared.logging import get_logger
from packages.shared.models import Ledger, QuotaRule
from packages.shared.observability import QUOTA_EVENTS
logger = get_logger(__name__)
@dataclass(frozen=True, slots=True)
class Reservation:
tx_id: int
user_id: int
quota_key: str
period_key: str
amount: int
remaining: int
idempotency_key: str
@dataclass(frozen=True, slots=True)
class QuotaSnapshot:
quota_key: str
period_key: str
used: int
limit_value: int
bonus_value: int
remaining: int
reset_at: datetime | None
class BillingService:
def __init__(self, session: AsyncSession, *, tz_offset_hours: int = 8) -> None:
self._session = session
self._tz_offset = tz_offset_hours
# -- read ---------------------------------------------------------------
async def get_quota(self, *, user_id: int, quota_key: str) -> QuotaSnapshot:
rule = await self._require_rule(quota_key)
period_type = cast(quota_rules.PeriodType, rule.period_type)
now = datetime.now(UTC)
period_key = quota_rules.period_key(now, period_type, self._tz_offset)
row = await repo.lock_or_create_quota(
self._session,
user_id=user_id,
quota_key=quota_key,
period_key=period_key,
limit_value=rule.base_limit,
)
state = quota_rules.QuotaState(
used=row.used, base_limit=row.limit_value, bonus=row.bonus_value
)
return QuotaSnapshot(
quota_key=quota_key,
period_key=period_key,
used=row.used,
limit_value=row.limit_value,
bonus_value=row.bonus_value,
remaining=state.remaining,
reset_at=quota_rules.period_reset_at(now, period_type, self._tz_offset),
)
async def list_ledger(self, *, user_id: int, limit: int = 50) -> list[Ledger]:
return await repo.list_ledger(self._session, user_id, limit)
# -- phase 1: reserve ---------------------------------------------------
async def reserve(
self,
*,
user_id: int,
quota_key: str,
request_key: str,
amount: int = 1,
reason: str | None = None,
ref_type: str | None = None,
ref_id: str | None = None,
) -> Reservation:
"""Freeze ``amount`` units. Idempotent on ``request_key``."""
idem_key = quota_rules.reserve_idempotency_key(
user_id=user_id, quota_key=quota_key, request_key=request_key
)
existing = await repo.get_ledger_by_idempotency_key(self._session, idem_key)
if existing is not None:
QUOTA_EVENTS.labels(quota_key, "reserve", "idempotent_hit").inc()
return Reservation(
tx_id=existing.tx_id,
user_id=user_id,
quota_key=quota_key,
period_key=existing.period_key or "",
amount=abs(existing.delta),
remaining=existing.balance_after,
idempotency_key=idem_key,
)
rule = await self._require_rule(quota_key)
period_type = cast(quota_rules.PeriodType, rule.period_type)
now = datetime.now(UTC)
period_key = quota_rules.period_key(now, period_type, self._tz_offset)
row = await repo.lock_or_create_quota(
self._session,
user_id=user_id,
quota_key=quota_key,
period_key=period_key,
limit_value=rule.base_limit,
)
state = quota_rules.QuotaState(
used=row.used, base_limit=row.limit_value, bonus=row.bonus_value
)
try:
new_state = quota_rules.reserve(state, amount)
except QuotaExceededError as exc:
QUOTA_EVENTS.labels(quota_key, "reserve", "exhausted").inc()
reset_at = quota_rules.period_reset_at(now, period_type, self._tz_offset)
raise QuotaExhaustedError(
required=exc.required,
remaining=exc.remaining,
reset_at=reset_at.isoformat() if reset_at else None,
) from exc
row.used = new_state.used
tx_id = next_id()
repo.add_ledger(
self._session,
Ledger(
tx_id=tx_id,
user_id=user_id,
delta=-amount,
tx_type="consume",
status="reserved",
reason=reason,
ref_type=ref_type,
ref_id=ref_id,
quota_key=quota_key,
period_key=period_key,
balance_after=quota_rules.balance_after(new_state),
idempotency_key=idem_key,
),
)
await self._session.flush()
QUOTA_EVENTS.labels(quota_key, "reserve", "ok").inc()
logger.info(
"quota_reserved",
tx_id=tx_id,
quota_key=quota_key,
amount=amount,
remaining=new_state.remaining,
)
return Reservation(
tx_id=tx_id,
user_id=user_id,
quota_key=quota_key,
period_key=period_key,
amount=amount,
remaining=new_state.remaining,
idempotency_key=idem_key,
)
# -- phase 2: commit ----------------------------------------------------
async def commit(self, reservation: Reservation) -> None:
"""Confirm the deduction. Safe to call twice."""
ledger = await repo.get_ledger(self._session, reservation.tx_id)
if ledger is None:
raise AppError(ErrorCode.STATE_CONFLICT, message="配额流水不存在")
if ledger.status == "reserved":
ledger.status = "committed"
await self._session.flush()
QUOTA_EVENTS.labels(reservation.quota_key, "commit", "ok").inc()
logger.info("quota_committed", tx_id=ledger.tx_id)
else:
QUOTA_EVENTS.labels(reservation.quota_key, "commit", "noop").inc()
# -- phase 3: refund ----------------------------------------------------
async def refund(self, reservation: Reservation, *, reason: str = "task_failed") -> None:
"""Release a reservation and write the reverse ledger entry.
FR-BILL-05 requires refunds to be idempotent; the refund ledger key is
derived from the reserve key so a retry can never double-refund.
"""
ledger = await repo.get_ledger(self._session, reservation.tx_id)
if ledger is None:
raise AppError(ErrorCode.STATE_CONFLICT, message="配额流水不存在")
if ledger.status != "reserved":
QUOTA_EVENTS.labels(reservation.quota_key, "refund", "noop").inc()
return
refund_key = quota_rules.refund_idempotency_key(reservation.idempotency_key)
if await repo.get_ledger_by_idempotency_key(self._session, refund_key) is not None:
QUOTA_EVENTS.labels(reservation.quota_key, "refund", "noop").inc()
return
rule = await self._require_rule(reservation.quota_key)
row = await repo.lock_or_create_quota(
self._session,
user_id=reservation.user_id,
quota_key=reservation.quota_key,
period_key=reservation.period_key,
limit_value=rule.base_limit,
)
state = quota_rules.QuotaState(
used=row.used, base_limit=row.limit_value, bonus=row.bonus_value
)
new_state = quota_rules.refund(state, reservation.amount)
row.used = new_state.used
ledger.status = "refunded"
repo.add_ledger(
self._session,
Ledger(
tx_id=next_id(),
user_id=reservation.user_id,
delta=reservation.amount,
tx_type="refund",
status="committed",
reason=reason,
ref_type=ledger.ref_type,
ref_id=ledger.ref_id,
quota_key=reservation.quota_key,
period_key=reservation.period_key,
balance_after=quota_rules.balance_after(new_state),
idempotency_key=refund_key,
),
)
await self._session.flush()
QUOTA_EVENTS.labels(reservation.quota_key, "refund", "ok").inc()
logger.info(
"quota_refunded",
tx_id=ledger.tx_id,
amount=reservation.amount,
remaining=new_state.remaining,
)
# -- helpers ------------------------------------------------------------
async def _require_rule(self, quota_key: str) -> QuotaRule:
rule = await repo.get_rule(self._session, quota_key)
if rule is None:
raise AppError(
ErrorCode.CONFIG_ERROR,
message="配额规则未配置",
data={"quota_key": quota_key},
)
return rule