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.
80 lines
2.4 KiB
Python
Executable File
80 lines
2.4 KiB
Python
Executable File
"""Data access for the billing domain. **Only this module may touch bl_* tables.**
|
|
|
|
架构文档 §3.2 硬性规则 1: 每个域只能通过对方暴露的 service 接口调用,禁止直接
|
|
查对方的表。import-linter enforces it (contract "no cross-domain repository access").
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from packages.shared.models import Ledger, Quota, QuotaRule
|
|
|
|
|
|
async def get_rule(session: AsyncSession, quota_key: str) -> QuotaRule | None:
|
|
return await session.get(QuotaRule, quota_key)
|
|
|
|
|
|
async def lock_or_create_quota(
|
|
session: AsyncSession,
|
|
*,
|
|
user_id: int,
|
|
quota_key: str,
|
|
period_key: str,
|
|
limit_value: int,
|
|
) -> Quota:
|
|
"""Get the period row with a row lock, creating it on first use.
|
|
|
|
``ON CONFLICT DO NOTHING`` + ``SELECT ... FOR UPDATE`` makes concurrent first
|
|
requests for the same period safe without an advisory lock.
|
|
"""
|
|
await session.execute(
|
|
pg_insert(Quota)
|
|
.values(
|
|
user_id=user_id,
|
|
quota_key=quota_key,
|
|
period_key=period_key,
|
|
used=0,
|
|
limit_value=limit_value,
|
|
bonus_value=0,
|
|
)
|
|
.on_conflict_do_nothing(index_elements=["user_id", "quota_key", "period_key"])
|
|
)
|
|
result = await session.execute(
|
|
select(Quota)
|
|
.where(
|
|
Quota.user_id == user_id,
|
|
Quota.quota_key == quota_key,
|
|
Quota.period_key == period_key,
|
|
)
|
|
.with_for_update()
|
|
)
|
|
row = result.scalar_one()
|
|
return row
|
|
|
|
|
|
async def get_ledger_by_idempotency_key(session: AsyncSession, key: str) -> Ledger | None:
|
|
result = await session.execute(select(Ledger).where(Ledger.idempotency_key == key))
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def get_ledger(session: AsyncSession, tx_id: int) -> Ledger | None:
|
|
return await session.get(Ledger, tx_id)
|
|
|
|
|
|
async def list_ledger(session: AsyncSession, user_id: int, limit: int = 50) -> list[Ledger]:
|
|
result = await session.execute(
|
|
select(Ledger)
|
|
.where(Ledger.user_id == user_id)
|
|
.order_by(Ledger.created_at.desc(), Ledger.tx_id.desc())
|
|
.limit(limit)
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
|
|
def add_ledger(session: AsyncSession, ledger: Ledger) -> Ledger:
|
|
session.add(ledger)
|
|
return ledger
|