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 属禁改,未动);单测改为覆盖受支持的空配置形态 "{}"。
This commit is contained in:
xsl
2026-08-14 16:23:14 +08:00
parent 9e7a886863
commit ecdfbb64c3
7 changed files with 587 additions and 5 deletions
+32 -3
View File
@@ -1,4 +1,4 @@
"""``GET /quota`` and ``GET /quota/ledger`` (架构文档 §9.3 配额).
"""``GET /quota``、``GET /quota/ledger``、``POST /quota/tasks/{task_key}/claim``.
FR-BILL-07: 额度相关文案中**不得出现任何价格、购买、会员、升级字样**。
Only two recovery paths may ever be surfaced: 等待恢复 and 做任务换额度.
@@ -6,11 +6,12 @@ Only two recovery paths may ever be surfaced: 等待恢复 and 做任务换额
from __future__ import annotations
from fastapi import APIRouter, Query
from fastapi import APIRouter, Path, Query
from apps.api.deps import BillingServiceDep, CurrentUserId
from apps.api.deps import BillingServiceDep, CurrentUserId, SettingsDep
from apps.api.schemas import LedgerEntry, QuotaResponse
from packages.agent.service import QUOTA_KEY_AGENT_CHAT
from packages.billing.schemas import TaskBonusClaimResponse
from packages.shared.response import ok
router = APIRouter(prefix="/quota", tags=["quota"])
@@ -59,3 +60,31 @@ async def get_ledger(
for row in rows
]
)
@router.post("/tasks/{task_key}/claim", summary="做任务换额度:领取任务奖励(FR-BILL-03)")
async def claim_task_bonus(
user_id: CurrentUserId,
billing: BillingServiceDep,
settings: SettingsDep,
task_key: str = Path(max_length=64),
) -> dict[str, object]:
"""同一任务同一周期只能领一次(重复领返回 40910),受规则封顶(40310).
奖励数值由 ``TASK_BONUS_RULES_JSON`` 配置驱动(FR-BILL-04),当前是占位值,
产品定稿后改 env 即生效。
"""
claim = await billing.claim_task_bonus(
user_id=user_id,
task_key=task_key,
rules=dict(settings.task_bonus_rules),
)
return ok(
TaskBonusClaimResponse(
task_key=claim.task_key,
quota_key=claim.quota_key,
amount=claim.amount,
bonus_granted=claim.bonus_granted,
remaining=claim.remaining,
).model_dump()
)