POST /quota/tasks/{task_key}/claim:配置驱动任务注册表、
grant_bonus 纯域函数、ledger 幂等键防重、task_bonus_max 封顶(FR-BILL-03/04)。
实现:packages/domain/billing/quota.py 新增 BonusDecision/grant_bonus(mypy strict,
恰好到顶允许、超一格整体拒绝)、grant_idempotency_key、PERIOD_TYPES;
packages/billing/service.py 新增 claim_task_bonus(幂等快捷路径 → 行锁 → 封顶判定
→ 同事务更新 bonus_granted 并写 system_grant 流水,delta=0 且 balance_after 按
remaining 口径;IntegrityError 并发兜底回滚为 40910);响应模型 TaskBonusClaimResponse
放新建的 packages/billing/schemas.py;路由挂在 apps/api/routers/quota.py,
rules 由 settings.task_bonus_rules 注入(amount 为占位值,产品定稿后改 env)。
测试:新增单测 11 个(封顶边界、amount<=0、不动 used、hard_cap 独立生效、
默认规则形状与 env 覆盖通路);新增集成 4 个(领取+重复 40910+纯 DB 流水断言、
五类任务全部到账、未知任务 40410、amount=11>max=10 → 40310 且无新流水)。
门禁全绿:ruff check / ruff format --check / mypy 两段 / lint-imports;
单测 180 passed,集成 7 passed。
偏差说明:1) 任务文档提到的 BillingService._load_rule 实际名为 _require_rule,
直接复用现有助手;2) packages/billing/repo.py 无需新增函数(现有
get_ledger_by_idempotency_key/lock_or_create_quota/add_ledger 已覆盖本任务);
3) 文档设想的「TASK_BONUS_RULES_JSON 空值容错」经 env 源实测不成立——
pydantic-settings 对 dict 字段先做 JSON 解码,空白字符串在 Settings 构造时即
报错(共享校验器/config.py 属禁改,未动);单测改为覆盖受支持的空配置形态 "{}"。
201 lines
6.8 KiB
Python
Executable File
201 lines
6.8 KiB
Python
Executable File
"""Quota arithmetic — pure logic, no IO (架构文档 §7.4 V1 配额三段式).
|
||
|
||
The three-phase protocol lives here as data transformations so it can be unit
|
||
tested without a database:
|
||
|
||
reserve → 冻结额度并写 bl_ledger(status=reserved)
|
||
commit → 确认扣除 (status=committed)
|
||
refund → 释放额度并写反向流水 (tx_type=refund)
|
||
|
||
V1 doesn't involve money, but 用户对「次数被白扣」的敏感度不比扣钱低,and V1
|
||
has no customer-service refund path — the mechanism is the only guarantee.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, replace
|
||
from datetime import UTC, datetime, timedelta, timezone
|
||
from typing import Literal, get_args
|
||
|
||
from packages.domain.errors import QuotaExceededError
|
||
|
||
PeriodType = Literal["day", "month", "lifetime"]
|
||
#: 合法周期取值集合,供配置校验复用;单一事实来源是上面的 Literal。
|
||
PERIOD_TYPES: frozenset[str] = frozenset(get_args(PeriodType))
|
||
LedgerStatus = Literal["reserved", "committed", "refunded"]
|
||
TxType = Literal["system_grant", "consume", "refund", "purchase", "gift"]
|
||
|
||
#: FR-BILL-01: 跨时区按 UTC+8 统一。
|
||
DEFAULT_TZ_OFFSET_HOURS = 8
|
||
|
||
|
||
def period_key(
|
||
now: datetime,
|
||
period_type: PeriodType,
|
||
tz_offset_hours: int = DEFAULT_TZ_OFFSET_HOURS,
|
||
) -> str:
|
||
"""Bucket key that makes quota reset naturally, with no cron job.
|
||
|
||
``day`` → ``d:2026-08-13``; ``month`` → ``m:2026-08``; ``lifetime`` → ``life``.
|
||
"""
|
||
if period_type == "lifetime":
|
||
return "life"
|
||
local = now.astimezone(UTC) + timedelta(hours=tz_offset_hours)
|
||
if period_type == "day":
|
||
return f"d:{local:%Y-%m-%d}"
|
||
if period_type == "month":
|
||
return f"m:{local:%Y-%m}"
|
||
msg = f"unknown period_type: {period_type}"
|
||
raise ValueError(msg)
|
||
|
||
|
||
def period_reset_at(
|
||
now: datetime,
|
||
period_type: PeriodType,
|
||
tz_offset_hours: int = DEFAULT_TZ_OFFSET_HOURS,
|
||
) -> datetime | None:
|
||
"""When the current period rolls over (used for the `reset_at` hint)."""
|
||
if period_type == "lifetime":
|
||
return None
|
||
tz = timezone(timedelta(hours=tz_offset_hours))
|
||
local = now.astimezone(tz)
|
||
if period_type == "day":
|
||
nxt = (local + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||
else:
|
||
year, month = (local.year + 1, 1) if local.month == 12 else (local.year, local.month + 1)
|
||
nxt = local.replace(
|
||
year=year, month=month, day=1, hour=0, minute=0, second=0, microsecond=0
|
||
)
|
||
return nxt.astimezone(UTC)
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class QuotaState:
|
||
"""Current period counters for one (user, quota_key)."""
|
||
|
||
used: int
|
||
base_limit: int
|
||
bonus: int = 0
|
||
hard_cap: int | None = None
|
||
|
||
@property
|
||
def effective_limit(self) -> int:
|
||
raw = self.base_limit + self.bonus
|
||
if self.hard_cap is None:
|
||
return raw
|
||
return min(raw, self.hard_cap)
|
||
|
||
@property
|
||
def remaining(self) -> int:
|
||
return max(self.effective_limit - self.used, 0)
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class QuotaDecision:
|
||
allowed: bool
|
||
required: int
|
||
remaining: int
|
||
effective_limit: int
|
||
|
||
|
||
def evaluate(state: QuotaState, amount: int) -> QuotaDecision:
|
||
"""Server-side hard check (FR-BILL-06). Client hints are UX only."""
|
||
if amount <= 0:
|
||
msg = "amount must be positive"
|
||
raise ValueError(msg)
|
||
return QuotaDecision(
|
||
allowed=state.used + amount <= state.effective_limit,
|
||
required=amount,
|
||
remaining=state.remaining,
|
||
effective_limit=state.effective_limit,
|
||
)
|
||
|
||
|
||
def reserve(state: QuotaState, amount: int) -> QuotaState:
|
||
"""Freeze ``amount`` units; raises :class:`QuotaExceededError` when over the limit."""
|
||
decision = evaluate(state, amount)
|
||
if not decision.allowed:
|
||
raise QuotaExceededError(required=amount, remaining=decision.remaining)
|
||
return replace(state, used=state.used + amount)
|
||
|
||
|
||
def refund(state: QuotaState, amount: int) -> QuotaState:
|
||
"""Release a previously reserved amount. Never drives ``used`` below zero."""
|
||
if amount <= 0:
|
||
msg = "amount must be positive"
|
||
raise ValueError(msg)
|
||
return replace(state, used=max(state.used - amount, 0))
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class BonusDecision:
|
||
"""任务奖励发放的封顶判定结果(FR-BILL-03).
|
||
|
||
``allowed=False`` 时 ``bonus/remaining/effective_limit`` 保持发放前的值:
|
||
调用方必须整体放弃,不做部分发放 ——「做任务换 N 次」对用户是明确承诺,
|
||
发一半既说不清也无法用流水对账。
|
||
"""
|
||
|
||
allowed: bool
|
||
amount: int
|
||
bonus: int
|
||
remaining: int
|
||
effective_limit: int
|
||
|
||
|
||
def grant_bonus(state: QuotaState, amount: int, task_bonus_max: int) -> BonusDecision:
|
||
"""把任务奖励 ``amount`` 计入 ``state.bonus``,超上限则整体拒绝(FR-BILL-03).
|
||
|
||
- 只动 ``bonus``,不动 ``used``:任务奖励抬高的是额度,不是用量;
|
||
- ``task_bonus_max`` 来自 ``bl_quota_rule``,是任务奖励独立于 ``hard_cap``
|
||
的第二道天花板(两者都生效时 effective_limit 取更紧的);
|
||
- 边界语义:恰好到顶(bonus + amount == max)允许,超出一格(== max+1)拒绝。
|
||
"""
|
||
if amount <= 0:
|
||
msg = "amount must be positive"
|
||
raise ValueError(msg)
|
||
if state.bonus + amount > task_bonus_max:
|
||
return BonusDecision(
|
||
allowed=False,
|
||
amount=amount,
|
||
bonus=state.bonus,
|
||
remaining=state.remaining,
|
||
effective_limit=state.effective_limit,
|
||
)
|
||
granted = replace(state, bonus=state.bonus + amount)
|
||
return BonusDecision(
|
||
allowed=True,
|
||
amount=amount,
|
||
bonus=granted.bonus,
|
||
remaining=granted.remaining,
|
||
effective_limit=granted.effective_limit,
|
||
)
|
||
|
||
|
||
def balance_after(state: QuotaState) -> int:
|
||
"""Value written to ``bl_ledger.balance_after``.
|
||
|
||
V1 has no wallet balance, so the ledger records *remaining quota* — that is
|
||
the number the user actually sees ("今日剩余 N 次"). When V2 introduces
|
||
``bl_wallet`` this becomes the wallet balance without a schema change.
|
||
"""
|
||
return state.remaining
|
||
|
||
|
||
def reserve_idempotency_key(*, user_id: int, quota_key: str, request_key: str) -> str:
|
||
return f"rsv:{user_id}:{quota_key}:{request_key}"
|
||
|
||
|
||
def grant_idempotency_key(*, user_id: int, task_key: str, claim_period_key: str) -> str:
|
||
"""FR-BILL-03 发放幂等:同一任务同一周期派生同一个键.
|
||
|
||
周期取任务的 ``claim_period``(不是额度行的周期)——「每天可领」的月度
|
||
额度任务,幂等粒度必须跟任务承诺走。
|
||
"""
|
||
return f"grant:{user_id}:{task_key}:{claim_period_key}"
|
||
|
||
|
||
def refund_idempotency_key(reserve_key: str) -> str:
|
||
"""FR-BILL-05: 退还必须幂等。Derived, so a retry cannot double-refund."""
|
||
return f"rfd:{reserve_key}"
|