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
+38
View File
@@ -0,0 +1,38 @@
# `packages/billing/` — 配额与计量(V1)
> ⚠️ **V1 不收费**(TD-28)。本域在 V1 的职责是**配额限制 + 计量**,
> 不含任何支付、订单、购买、会员能力。
## 已实现
- `bl_quota_rule`(规则配置化,改配置不发版,FR-BILL-04)
- `bl_quota`(按 `period_key` 自然重置,无需定时任务清零)
- `bl_ledger`(流水是真相,余额是缓存)
- **预扣三段式** `reserve → commit → refund`(架构 §7.4,FR-BILL-05)
- 服务端硬校验(FR-BILL-06);退还幂等(refund 键由 reserve 键派生)
## 架构约束(不可妥协)
**V1 的配额账本与 V2 的点数账本必须是同一张表。** V1 只用 `system_grant` /
`consume` / `refund` 三种 `tx_type`,V2 增加 `purchase` / `gift`。这样 V2 上
支付时不需要数据迁移,也不需要重写扣减逻辑。**不要为了"V1 更简单"把表拆开。**
## 为什么是预扣不是直接扣
长耗时任务可能失败,直接扣会产生「扣了额度没拿到结果」的客诉。V1 虽不涉及
金钱,**用户对"次数被白扣"的敏感度不比扣钱低**,而且 V1 没有客服退款兜底手段,
只能靠机制保证。
## 待实现
- FR-BILL-03 任务奖励发放(签到/完善资料/反馈/邀请/分享,同任务同周期幂等)
- **每日对账**:余额由流水重算校验,不一致则告警。V1 就要做,否则 V2 上支付时
对账逻辑得从零开发且无历史数据验证。
- 设备/账号/IP 三重限流(FR-BILL-06)
- Redis 配额计数器 + PG 落账的两级结构(架构 §10)
## 文案约束(FR-BILL-07)
额度相关提示里**不得出现任何价格、购买、会员、升级字样**,只提供「等待恢复」
与「做任务换额度」两条路径。`tests/unit/test_response_envelope.py` 会扫描全部
错误码文案。
+1
View File
@@ -0,0 +1 @@
"""billing 域(V1):配额账本、流水、预扣三段式。不含支付。"""
+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
+267
View File
@@ -0,0 +1,267 @@
"""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