Files
xsl ecdfbb64c3 feat(billing): task bonus claim endpoint with idempotency and cap
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 属禁改,未动);单测改为覆盖受支持的空配置形态 "{}"。
2026-08-14 16:23:14 +08:00

75 lines
3.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""grant_bonus 纯域函数:封顶边界与不变量(FR-BILL-03)."""
from __future__ import annotations
import pytest
from packages.domain.billing import quota
def test_grant_up_to_the_cap_exactly_is_allowed() -> None:
# bonus + amount == max:恰好到顶,允许。
decision = quota.grant_bonus(quota.QuotaState(used=0, base_limit=10, bonus=9), 1, 10)
assert decision.allowed
assert decision.bonus == 10
assert decision.effective_limit == 20
assert decision.remaining == 20
def test_grant_one_over_the_cap_is_rejected_as_a_whole() -> None:
# bonus + amount == max + 1:整体拒绝,不做部分发放。
state = quota.QuotaState(used=4, base_limit=10, bonus=9)
decision = quota.grant_bonus(state, 2, 10)
assert not decision.allowed
# 拒绝时保持发放前的值 —— 调用方不得据此落库。
assert decision.bonus == 9
assert decision.remaining == state.remaining
assert decision.effective_limit == state.effective_limit
def test_zero_cap_rejects_every_grant() -> None:
# hd_export / report_basic 的 task_bonus_max=0:任务奖励一律拒绝。
decision = quota.grant_bonus(quota.QuotaState(used=0, base_limit=1), 1, 0)
assert not decision.allowed
def test_grant_rejects_non_positive_amounts() -> None:
state = quota.QuotaState(used=0, base_limit=10)
with pytest.raises(ValueError, match="positive"):
quota.grant_bonus(state, 0, 10)
with pytest.raises(ValueError, match="positive"):
quota.grant_bonus(state, -1, 10)
def test_grant_never_touches_used_or_base_limit() -> None:
# 任务奖励抬高的是额度不是用量:remaining 的增量恰好等于 amount。
state = quota.QuotaState(used=7, base_limit=10, bonus=2)
decision = quota.grant_bonus(state, 3, 10)
assert decision.allowed
assert decision.remaining - state.remaining == 3
assert decision.bonus == 5
def test_task_bonus_cap_is_independent_of_hard_cap() -> None:
# 两道天花板独立生效:task_bonus_max 放行、hard_cap 把 effective_limit 削回来。
state = quota.QuotaState(used=0, base_limit=3, bonus=3, hard_cap=5)
decision = quota.grant_bonus(state, 3, 10)
assert decision.allowed # bonus 6 <= task_bonus_max 10
assert decision.effective_limit == 5 # base 3 + bonus 6 被 hard_cap 削到 5
assert decision.remaining == 5
def test_grant_key_is_derived_per_user_task_and_claim_period() -> None:
key = quota.grant_idempotency_key(
user_id=7, task_key="sign_in", claim_period_key="d:2026-08-14"
)
assert key == "grant:7:sign_in:d:2026-08-14"
# 派生键确定:同一周期重复 claim 得到同一个键,唯一约束才能兜底。
assert key == quota.grant_idempotency_key(
user_id=7, task_key="sign_in", claim_period_key="d:2026-08-14"
)
# 换周期换键:下个周期可以再领。
assert key != quota.grant_idempotency_key(
user_id=7, task_key="sign_in", claim_period_key="d:2026-08-15"
)