The product plan is the only source for quota numbers, but the 0001 seed used a second set (agent_chat 20/50, tryon hard 10) and omitted hairstyle, hd_export and report regen. Migration 0003 replaces the seed and renames points_reserved → quota_reserved and bonus_value → bonus_granted so the docs and the schema stop contradicting each other. Effective limit now clips at hard_cap. CI runs the integration slice against the Postgres/Redis services it already starts.
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_granted=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
|