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.
This commit is contained in:
xsl
2026-08-14 00:26:39 +08:00
parent 54dc741811
commit 7c67506474
36 changed files with 1213 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
"""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